dispatch page
This commit is contained in:
BIN
FIESTA_BACKEND_API.docx
Normal file
BIN
FIESTA_BACKEND_API.docx
Normal file
Binary file not shown.
10
package-lock.json
generated
10
package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.0.1",
|
"react": "^19.0.1",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
@@ -3307,6 +3308,15 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode.react": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.2",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.0.1",
|
"react": "^19.0.1",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
|||||||
32
src/App.tsx
32
src/App.tsx
@@ -102,12 +102,17 @@ export default function App() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
|
||||||
|
// Scope every Fiesta query to the signed-in merchant. The login record carries
|
||||||
|
// the user's tenantid; fall back to the shared constant only when it's absent
|
||||||
|
// (e.g. a legacy session before tenantid was captured) so the page still loads.
|
||||||
|
const tenantId = authUser?.tenantid || FIESTA_TENANT_ID;
|
||||||
|
|
||||||
// ── Live data for the secondary sections (Fiesta) ─────────────────────────
|
// ── Live data for the secondary sections (Fiesta) ─────────────────────────
|
||||||
// Stores ← tenant locations + per-location order summary (seeded into local
|
// Stores ← tenant locations + per-location order summary (seeded into local
|
||||||
// state so the "Add Store" handler keeps working). Users management now lives
|
// state so the "Add Store" handler keeps working). Users management now lives
|
||||||
// under Settings → Users & Access (see UsersPanel).
|
// under Settings → Users & Access (see UsersPanel).
|
||||||
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
const locationsQ = useFiestaTenantLocations(tenantId);
|
||||||
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
const locSummaryQ = useFiestaLocationSummary(tenantId);
|
||||||
|
|
||||||
const STORE_COVERS = [
|
const STORE_COVERS = [
|
||||||
'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=600&q=80',
|
'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=600&q=80',
|
||||||
@@ -141,12 +146,14 @@ export default function App() {
|
|||||||
const [storesFilter, setStoresFilter] = useState<'ALL' | 'ACTIVE' | 'CRITICAL'>('ALL');
|
const [storesFilter, setStoresFilter] = useState<'ALL' | 'ACTIVE' | 'CRITICAL'>('ALL');
|
||||||
|
|
||||||
const filteredStoresList = storesList.filter((st) => {
|
const filteredStoresList = storesList.filter((st) => {
|
||||||
const q = storesSearch.toLowerCase();
|
const q = storesSearch.trim().toLowerCase();
|
||||||
const matchesSearch =
|
// Match across every field shown on the card — name, zone, manager/contact,
|
||||||
!q ||
|
// and the outlet id — coercing each to a string so a missing/numeric value
|
||||||
st.name.toLowerCase().includes(q) ||
|
// never throws and silently breaks the whole filter.
|
||||||
st.zone.toLowerCase().includes(q) ||
|
const haystack = [st.name, st.zone, st.staff, st.locationid]
|
||||||
st.staff.toLowerCase().includes(q);
|
.map((v) => String(v ?? '').toLowerCase())
|
||||||
|
.join(' ');
|
||||||
|
const matchesSearch = !q || haystack.includes(q);
|
||||||
|
|
||||||
if (storesFilter === 'ACTIVE') {
|
if (storesFilter === 'ACTIVE') {
|
||||||
return matchesSearch && st.status.toLowerCase() === 'active';
|
return matchesSearch && st.status.toLowerCase() === 'active';
|
||||||
@@ -262,6 +269,7 @@ export default function App() {
|
|||||||
<StoreDetailView
|
<StoreDetailView
|
||||||
store={activeStore}
|
store={activeStore}
|
||||||
onBack={selectedStore ? () => setSelectedStore(null) : undefined}
|
onBack={selectedStore ? () => setSelectedStore(null) : undefined}
|
||||||
|
tenantId={tenantId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -533,7 +541,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return <SettingsView tenantId={FIESTA_TENANT_ID} />;
|
return <SettingsView tenantId={tenantId} />;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
@@ -575,6 +583,7 @@ export default function App() {
|
|||||||
isCoimbatoreView={isCoimbatoreView}
|
isCoimbatoreView={isCoimbatoreView}
|
||||||
setIsCoimbatoreView={setIsCoimbatoreView}
|
setIsCoimbatoreView={setIsCoimbatoreView}
|
||||||
isOpen={sidebarOpen}
|
isOpen={sidebarOpen}
|
||||||
|
isAdmin={authRole === 'admin'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main core pages payload area */}
|
{/* Main core pages payload area */}
|
||||||
@@ -582,13 +591,14 @@ export default function App() {
|
|||||||
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
||||||
{/* Nav content routing */}
|
{/* Nav content routing */}
|
||||||
{currentSection === 'dashboard' && (
|
{currentSection === 'dashboard' && (
|
||||||
<DashboardView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} />
|
<DashboardView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} tenantId={tenantId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentSection === 'inventory' && (
|
{currentSection === 'inventory' && (
|
||||||
<InventoryView
|
<InventoryView
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
isCoimbatoreView={isCoimbatoreView}
|
isCoimbatoreView={isCoimbatoreView}
|
||||||
|
tenantId={tenantId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -597,9 +607,11 @@ export default function App() {
|
|||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
isCoimbatoreView={isCoimbatoreView}
|
isCoimbatoreView={isCoimbatoreView}
|
||||||
setIsCoimbatoreView={setIsCoimbatoreView}
|
setIsCoimbatoreView={setIsCoimbatoreView}
|
||||||
|
tenantId={tenantId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Handle alternative sections: Stores, Settings */}
|
{/* Handle alternative sections: Stores, Settings */}
|
||||||
{['stores', 'settings'].includes(currentSection) &&
|
{['stores', 'settings'].includes(currentSection) &&
|
||||||
renderSecondarySection()
|
renderSecondarySection()
|
||||||
|
|||||||
155
src/components/AddressAutocomplete.tsx
Normal file
155
src/components/AddressAutocomplete.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1553
src/components/AdminConsole.tsx
Normal file
1553
src/components/AdminConsole.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,14 +15,16 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useOrderSummary, useTenantInfo, useTenantLocations, useInvoiceInsight } from '../services/queries';
|
import { useOrderSummary, useTenantInfo, useInvoiceInsight } from '../services/queries';
|
||||||
import { DEFAULT_TENANT_ID, DEFAULT_CONFIG_ID } from '../services/api';
|
import { DEFAULT_CONFIG_ID } from '../services/api';
|
||||||
import { useFiestaLocationSummary } from '../services/fiestaQueries';
|
import { useFiestaLocationSummary, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID } from '../services/fiestaApi';
|
||||||
|
|
||||||
interface DashboardViewProps {
|
interface DashboardViewProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
isCoimbatoreView: boolean;
|
isCoimbatoreView: boolean;
|
||||||
|
/** Fiesta merchant tenant to scope live store summaries to. */
|
||||||
|
tenantId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ymd = (d: Date) =>
|
const ymd = (d: Date) =>
|
||||||
@@ -30,20 +32,23 @@ const ymd = (d: Date) =>
|
|||||||
|
|
||||||
const str = (v: unknown): string => (v == null ? '' : String(v));
|
const str = (v: unknown): string => (v == null ? '' : String(v));
|
||||||
|
|
||||||
export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID }: DashboardViewProps) {
|
||||||
// Live data — month-to-date order summary + tenant identity + store locations.
|
// Live data — month-to-date order summary + tenant identity + store locations.
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
const fromdate = ymd(monthStart);
|
const fromdate = ymd(monthStart);
|
||||||
const todate = ymd(today);
|
const todate = ymd(today);
|
||||||
|
|
||||||
const summaryQ = useOrderSummary(DEFAULT_TENANT_ID, fromdate, todate, DEFAULT_CONFIG_ID);
|
// All scoped to the signed-in merchant's tenant. Store locations come from the
|
||||||
const tenantQ = useTenantInfo(DEFAULT_TENANT_ID);
|
// Fiesta source (the single source of truth used across the app) — it's already
|
||||||
const locationsQ = useTenantLocations(DEFAULT_TENANT_ID);
|
// deduped and stripped of test rows, unlike the raw Hasura tenant-locations feed.
|
||||||
const insightQ = useInvoiceInsight(DEFAULT_TENANT_ID);
|
const summaryQ = useOrderSummary(tenantId, fromdate, todate, DEFAULT_CONFIG_ID);
|
||||||
|
const tenantQ = useTenantInfo(tenantId);
|
||||||
|
const locationsQ = useFiestaTenantLocations(tenantId);
|
||||||
|
const insightQ = useInvoiceInsight(tenantId);
|
||||||
|
|
||||||
const s = summaryQ.data;
|
const s = summaryQ.data;
|
||||||
const tenantName = str(tenantQ.data?.tenantname) || s?.tenantname || `Tenant ${DEFAULT_TENANT_ID}`;
|
const tenantName = str(tenantQ.data?.tenantname) || s?.tenantname || `Tenant ${tenantId}`;
|
||||||
|
|
||||||
// Revenue + profit come from the live invoice/financial insight. The endpoint
|
// Revenue + profit come from the live invoice/financial insight. The endpoint
|
||||||
// returns two distinct figures (revenue and profit); we surface both rather than
|
// returns two distinct figures (revenue and profit); we surface both rather than
|
||||||
@@ -54,7 +59,7 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
|||||||
const monthlyRevenue = insight ? insight.revenue : null;
|
const monthlyRevenue = insight ? insight.revenue : null;
|
||||||
const monthlyProfit = insight ? insight.profit : null;
|
const monthlyProfit = insight ? insight.profit : null;
|
||||||
|
|
||||||
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
const locSummaryQ = useFiestaLocationSummary(tenantId);
|
||||||
const summaries = locSummaryQ.data ?? [];
|
const summaries = locSummaryQ.data ?? [];
|
||||||
|
|
||||||
// Region fulfillment — live month-to-date delivered ÷ total orders for the tenant.
|
// Region fulfillment — live month-to-date delivered ÷ total orders for the tenant.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike,
|
Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -26,7 +27,7 @@ import {
|
|||||||
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge,
|
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge,
|
||||||
} from './consoleUi';
|
} from './consoleUi';
|
||||||
|
|
||||||
interface DeliveriesViewProps { searchQuery?: string; locationid?: number; }
|
interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; }
|
||||||
|
|
||||||
type DeliveryStatus = 'pending' | 'accepted' | 'arrived' | 'picked' | 'active' | 'skipped' | 'delivered' | 'cancelled';
|
type DeliveryStatus = 'pending' | 'accepted' | 'arrived' | 'picked' | 'active' | 'skipped' | 'delivered' | 'cancelled';
|
||||||
const STATUS_TABS: Array<{ key: DeliveryStatus; label: string }> = [
|
const STATUS_TABS: Array<{ key: DeliveryStatus; label: string }> = [
|
||||||
@@ -55,34 +56,32 @@ function inBatch(r: Row, b: BatchId): boolean {
|
|||||||
if (b === 'afternoon') return h >= 9 && h < 12.5;
|
if (b === 'afternoon') return h >= 9 && h < 12.5;
|
||||||
return h >= 16 && h < 19;
|
return h >= 16 && h < 19;
|
||||||
}
|
}
|
||||||
function initialBatch(): BatchId {
|
|
||||||
const h = new Date().getHours();
|
|
||||||
if (h >= 0 && h < 8) return 'morning';
|
|
||||||
if (h >= 9 && h < 12.5) return 'afternoon';
|
|
||||||
if (h >= 16 && h < 19) return 'evening';
|
|
||||||
return 'all';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeliveriesView({ searchQuery = '', locationid }: DeliveriesViewProps) {
|
export default function DeliveriesView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID }: DeliveriesViewProps) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const [fromdate, setFromdate] = useState<string>(ymd(today));
|
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
const [todate, setTodate] = useState<string>(ymd(today));
|
|
||||||
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); };
|
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); };
|
||||||
|
const dayAhead = (n: number) => { const d = new Date(); d.setDate(d.getDate() + n); return ymd(d); };
|
||||||
|
const [fromdate, setFromdate] = useState<string>(dayOffset(6));
|
||||||
|
const [todate, setTodate] = useState<string>(ymd(today));
|
||||||
const presets = [
|
const presets = [
|
||||||
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
||||||
{ key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) },
|
|
||||||
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
|
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
|
||||||
|
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: dayAhead(7) },
|
||||||
];
|
];
|
||||||
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
||||||
|
|
||||||
const [batch, setBatch] = useState<BatchId>(initialBatch());
|
const batch: BatchId = 'all';
|
||||||
const [status, setStatus] = useState<DeliveryStatus>('pending');
|
const [status, setStatus] = useState<DeliveryStatus>('pending');
|
||||||
const [localSearch, setLocalSearch] = useState('');
|
const [localSearch, setLocalSearch] = useState('');
|
||||||
const [detailRow, setDetailRow] = useState<Row | null>(null);
|
const [detailRow, setDetailRow] = useState<Row | null>(null);
|
||||||
|
|
||||||
const summaryQ = useFiestaDeliverySummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
// Scope to the user's store when a locationid is supplied (server-side per the
|
||||||
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
// backend's deliverysummary/getdeliveries locationid param). getDeliveries loads
|
||||||
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
|
// the whole day (status='all', large pagesize); status/search filter client-side.
|
||||||
|
const summaryQ = useFiestaDeliverySummary({ tenantid: tenantId, fromdate, todate, locationid });
|
||||||
|
const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate, todate, locationid, status: 'all', pagesize: 200 });
|
||||||
|
const ridersQ = useFiestaRiders({ tenantid: tenantId });
|
||||||
|
|
||||||
const allRows = deliveriesQ.data ?? [];
|
const allRows = deliveriesQ.data ?? [];
|
||||||
const summary = summaryQ.data;
|
const summary = summaryQ.data;
|
||||||
@@ -143,70 +142,70 @@ export default function DeliveriesView({ searchQuery = '', locationid }: Deliver
|
|||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<input type="date" value={fromdate} max={todate} onChange={(e) => setFromdate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
<input type="date" value={fromdate} max={todate} onChange={(e) => setFromdate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||||
<span style={{ color: TEXT_3 }}>→</span>
|
<span style={{ color: TEXT_3 }}>→</span>
|
||||||
<input type="date" value={todate} min={fromdate} max={ymd(today)} onChange={(e) => setTodate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
<input type="date" value={todate} min={fromdate} onChange={(e) => setTodate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap pt-3 mt-3 border-t" style={{ borderColor: DIVIDER }}>
|
|
||||||
<span className="text-[10px] font-extrabold uppercase tracking-widest pr-1" style={{ color: TEXT_2 }}>Wave</span>
|
|
||||||
{BATCHES.map((b) => {
|
|
||||||
const Icon = b.icon;
|
|
||||||
const count = allRows.filter((r) => (!locationid || fnum(r.locationid) === locationid) && inBatch(r, b.id)).length;
|
|
||||||
return (
|
|
||||||
<React.Fragment key={b.id}>
|
|
||||||
<Pill active={batch === b.id} color={b.color} onClick={() => setBatch(b.id)} title={b.range} count={count}><Icon size={13} /> {b.label}</Pill>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
{/* Status tabs + search */}
|
{/* Status tabs + search */}
|
||||||
<FilterBar className="mb-4">
|
<FilterBar className="mb-4">
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
|
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
|
||||||
<div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0">
|
<div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0">
|
||||||
{STATUS_TABS.map((t) => {
|
{STATUS_TABS.map((t) => (
|
||||||
const color = statusColor(DELIVERY_STATUS, t.key);
|
|
||||||
return (
|
|
||||||
<React.Fragment key={t.key}>
|
<React.Fragment key={t.key}>
|
||||||
<Pill active={status === t.key} color={color} onClick={() => setStatus(t.key)} count={statusCounts[t.key] ?? 0}>{t.label}</Pill>
|
<Pill active={status === t.key} color={BRAND} onClick={() => setStatus(t.key)} count={statusCounts[t.key] ?? 0}>{t.label}</Pill>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full lg:w-72 lg:shrink-0"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search by order, rider…" color="#6366f1" /></div>
|
<div className="w-full lg:w-72 lg:shrink-0"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search by order, rider…" /></div>
|
||||||
</div>
|
</div>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full" style={{ minWidth: 1040 }}>
|
<table className="w-full" style={{ minWidth: 1240 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>{['#', 'Status', 'Order', 'Drop', 'Rider', 'ETA', 'KMs', 'Amount', ''].map((h, i) => (<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>))}</tr>
|
<tr>{['S.No', 'Tenant', 'Order ID', 'Pickup', 'Delivery', 'Rider', 'KMS', 'Amount', 'Status', 'Notes', 'Action'].map((h, i) => (<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>))}</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{deliveriesQ.isLoading ? (
|
{deliveriesQ.isLoading ? (
|
||||||
<tr><td colSpan={9} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}><span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading deliveries…</span></td></tr>
|
<tr><td colSpan={11} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}><span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading deliveries…</span></td></tr>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<tr><td colSpan={9} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No {status} deliveries in this wave. Try another status, wave, or date.</td></tr>
|
<tr><td colSpan={11} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No {status} deliveries in this wave. Try another status, wave, or date.</td></tr>
|
||||||
) : (
|
) : (
|
||||||
rows.map((r, i) => {
|
rows.map((r, i) => {
|
||||||
const st = fstr(r.orderstatus).toLowerCase();
|
const st = fstr(r.orderstatus).toLowerCase();
|
||||||
const rider = fstr(r.ridername) || fstr(r.username);
|
const rider = fstr(r.ridername) || fstr(r.username);
|
||||||
const kms = fnum(r.kms); const actualKms = fnum(r.cumulativekms);
|
const kms = fnum(r.kms); const actualKms = fnum(r.actualkms) || fnum(r.riderkms);
|
||||||
const charge = fnum(r.deliverycharges); const amt = fnum(r.deliveryamt);
|
const charge = fnum(r.deliverycharges); const amt = fnum(r.deliveryamt);
|
||||||
|
const tenant = fstr(r.tenantname) || fstr(r.pickupcustomer);
|
||||||
|
// Pickup/Delivery: the backend often leaves customer/contact blank for
|
||||||
|
// app-created jobs but populates the address — fall back so cells aren't bare.
|
||||||
|
const pickupName = fstr(r.pickupcustomer) || fstr(r.pickupcontactno);
|
||||||
|
const pickupAddr = fstr(r.pickupsuburb) || fstr(r.pickuplocation) || fstr(r.Pickupaddress) || fstr(r.pickupaddress);
|
||||||
|
const dropName = fstr(r.deliverycustomer) || fstr(r.deliverycontactno);
|
||||||
|
const dropAddr = fstr(r.deliveryaddress) || fstr(r.deliverylocation) || fstr(r.deliverysuburb);
|
||||||
|
const notes = fstr(r.ordernotes) || fstr(r.notes);
|
||||||
return (
|
return (
|
||||||
<tr key={fstr(r.deliveryid) || fstr(r.orderid) || i} className="transition-colors align-top" style={{ borderBottom: `1px solid ${DIVIDER}` }}
|
<tr key={fstr(r.deliveryid) || fstr(r.orderid) || i} className="transition-colors align-top" style={{ borderBottom: `1px solid ${DIVIDER}` }}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
|
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
|
||||||
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td>
|
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td>
|
||||||
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></td>
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{tenant || '—'}</p>
|
||||||
|
{fstr(r.tenantcity) && <p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.tenantcity)}</p>}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}</p>
|
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}</p>
|
||||||
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.assigntime || r.deliverydate)}</p>
|
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.assigntime || r.deliverydate)}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
|
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{pickupName || pickupAddr || '—'}</p>
|
||||||
<p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
<p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{pickupName ? pickupAddr : ''}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{dropName || dropAddr || '—'}</p>
|
||||||
|
<p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{dropName ? dropAddr : ''}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
{rider ? (
|
{rider ? (
|
||||||
@@ -216,7 +215,6 @@ export default function DeliveriesView({ searchQuery = '', locationid }: Deliver
|
|||||||
</span>
|
</span>
|
||||||
) : <span className="text-[11px] italic" style={{ color: TEXT_3 }}>Unassigned</span>}
|
) : <span className="text-[11px] italic" style={{ color: TEXT_3 }}>Unassigned</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5"><MetricPill color="#06b6d4">{shortTime(r.expecteddeliverytime) || '—'}</MetricPill></td>
|
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
<MetricPill color="#ef4444" minWidth={64}>{kms ? kms.toFixed(1) : '—'}</MetricPill>
|
<MetricPill color="#ef4444" minWidth={64}>{kms ? kms.toFixed(1) : '—'}</MetricPill>
|
||||||
@@ -230,6 +228,10 @@ export default function DeliveriesView({ searchQuery = '', locationid }: Deliver
|
|||||||
{charge === 0 && amt === 0 && <span style={{ color: TEXT_3 }}>—</span>}
|
{charge === 0 && amt === 0 && <span style={{ color: TEXT_3 }}>—</span>}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="text-[11px] truncate max-w-[160px]" style={{ color: notes ? TEXT_2 : TEXT_3 }} title={notes}>{notes || '—'}</p>
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2.5 text-right">
|
<td className="px-3 py-2.5 text-right">
|
||||||
<button onClick={() => setDetailRow(r)} className="rounded-full font-extrabold cursor-pointer" style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>View</button>
|
<button onClick={() => setDetailRow(r)} className="rounded-full font-extrabold cursor-pointer" style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>View</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -262,13 +264,15 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }
|
|||||||
const st = fstr(row.orderstatus).toLowerCase();
|
const st = fstr(row.orderstatus).toLowerCase();
|
||||||
const rider = fstr(row.ridername) || fstr(row.username);
|
const rider = fstr(row.ridername) || fstr(row.username);
|
||||||
const steps = [
|
const steps = [
|
||||||
{ label: 'Assigned', field: 'assigntime' }, { label: 'Accepted', field: 'acceptedtime' }, { label: 'Arrived', field: 'arrivaltime' },
|
{ label: 'Assigned', field: 'assigntime' }, { label: 'Accepted', field: 'starttime' }, { label: 'Arrived', field: 'arrivaltime' },
|
||||||
{ label: 'Picked', field: 'pickuptime' }, { label: 'Delivered', field: 'deliverytime' },
|
{ label: 'Picked', field: 'pickuptime' }, { label: 'Delivered', field: 'deliverytime' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
// Portal to <body> so `fixed inset-0` is viewport-relative even when an ancestor
|
||||||
|
// in the view tree is transformed/blurred (otherwise the panel collapses).
|
||||||
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
<div className="bg-white w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200" style={{ border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
|
<div className="bg-white max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
|
||||||
<div style={{ height: 4, background: `linear-gradient(90deg, #6366f1 0%, ${soft('#6366f1')} 100%)` }} />
|
<div style={{ height: 4, background: `linear-gradient(90deg, #6366f1 0%, ${soft('#6366f1')} 100%)` }} />
|
||||||
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||||
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Truck size={16} style={{ color: '#6366f1' }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}</h4>
|
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Truck size={16} style={{ color: '#6366f1' }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}</h4>
|
||||||
@@ -280,9 +284,9 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }
|
|||||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_2 }}><UserCheck size={12} /> {rider || 'Unassigned'}</span>
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_2 }}><UserCheck size={12} /> {rider || 'Unassigned'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded-xl space-y-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
<div className="p-3 rounded-xl space-y-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
||||||
<div className="font-bold" style={{ color: TEXT }}>{fstr(row.deliverycustomer) || 'Customer'}</div>
|
<div className="font-bold" style={{ color: TEXT }}>{fstr(row.deliverycustomer) || fstr(row.deliverycontactno) || 'Customer'}</div>
|
||||||
{fstr(row.deliverycontactno) && <div className="flex items-center gap-2 font-mono text-xs" style={{ color: TEXT_2 }}><Phone size={12} /> {fstr(row.deliverycontactno)}</div>}
|
{fstr(row.deliverycontactno) && <div className="flex items-center gap-2 font-mono text-xs" style={{ color: TEXT_2 }}><Phone size={12} /> {fstr(row.deliverycontactno)}</div>}
|
||||||
<div className="flex items-start gap-2 text-xs" style={{ color: TEXT_2 }}><MapPin size={12} className="mt-0.5 shrink-0" /> <span className="leading-relaxed">{fstr(row.deliveryaddress) || fstr(row.deliverysuburb) || 'Address unavailable'}</span></div>
|
<div className="flex items-start gap-2 text-xs" style={{ color: TEXT_2 }}><MapPin size={12} className="mt-0.5 shrink-0" /> <span className="leading-relaxed">{fstr(row.deliveryaddress) || fstr(row.deliverylocation) || fstr(row.deliverysuburb) || 'Address unavailable'}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2" style={{ color: TEXT_2 }}>Delivery Timeline</span>
|
<span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2" style={{ color: TEXT_2 }}>Delivery Timeline</span>
|
||||||
@@ -318,6 +322,7 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }
|
|||||||
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}>Close</button>
|
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,27 +14,23 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { TrendingUp, Clock, CheckCircle2, IndianRupee, Bike, Truck, Calendar, Download, Store, ClipboardList, Route } from 'lucide-react';
|
import { TrendingUp, Clock, CheckCircle2, IndianRupee, Bike, Truck, Calendar, Store } from 'lucide-react';
|
||||||
import { useFiestaLocationSummary, useFiestaFleetSummary, useFiestaDeliveries } from '../services/fiestaQueries';
|
import { useFiestaLocationSummary, useFiestaFleetSummary } from '../services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||||
import { shortTime } from '../services/fiestaMappers';
|
|
||||||
import AwaitingApi from './AwaitingApi';
|
|
||||||
import {
|
import {
|
||||||
GradientHeader, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
|
GradientHeader, KpiStrip, Pill, StatusChip, MetricPill, FilterBar, TH_STYLE,
|
||||||
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, ring,
|
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, ring,
|
||||||
} from './consoleUi';
|
} from './consoleUi';
|
||||||
|
|
||||||
type ReportTab = 'orders-summary' | 'riders-summary' | 'orders-details' | 'maps';
|
type ReportTab = 'orders-summary' | 'riders-summary';
|
||||||
const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> = [
|
const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> = [
|
||||||
{ key: 'orders-summary', label: 'Orders Summary', icon: Store },
|
{ key: 'orders-summary', label: 'Orders Summary', icon: Store },
|
||||||
{ key: 'riders-summary', label: 'Riders Summary', icon: Bike },
|
{ key: 'riders-summary', label: 'Riders Summary', icon: Bike },
|
||||||
{ key: 'orders-details', label: 'Orders Details', icon: ClipboardList },
|
|
||||||
{ key: 'maps', label: 'Rider Routes', icon: Route },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface DeliveryReportsViewProps { searchQuery?: string; }
|
interface DeliveryReportsViewProps { searchQuery?: string; tenantId?: number; }
|
||||||
|
|
||||||
export default function DeliveryReportsView({ searchQuery = '' }: DeliveryReportsViewProps) {
|
export default function DeliveryReportsView({ searchQuery = '', tenantId = FIESTA_TENANT_ID }: DeliveryReportsViewProps) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
const [fromdate, setFromdate] = useState<string>(ymd(monthStart));
|
const [fromdate, setFromdate] = useState<string>(ymd(monthStart));
|
||||||
@@ -52,7 +48,7 @@ export default function DeliveryReportsView({ searchQuery = '' }: DeliveryReport
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in duration-300">
|
<div className="animate-in fade-in duration-300">
|
||||||
<GradientHeader title="Delivery Reports" subtitle="Operational analytics across outlets, riders, and the full order lifecycle." />
|
<GradientHeader title="Reports" subtitle="Operational analytics across outlets, riders, and the full order lifecycle." />
|
||||||
|
|
||||||
{/* Tab nav */}
|
{/* Tab nav */}
|
||||||
<FilterBar className="mb-4">
|
<FilterBar className="mb-4">
|
||||||
@@ -85,15 +81,8 @@ export default function DeliveryReportsView({ searchQuery = '' }: DeliveryReport
|
|||||||
</div>
|
</div>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
{tab === 'orders-summary' && <OrdersSummaryReport />}
|
{tab === 'orders-summary' && <OrdersSummaryReport tenantId={tenantId} />}
|
||||||
{tab === 'riders-summary' && <RidersSummaryReport fromdate={fromdate} todate={todate} />}
|
{tab === 'riders-summary' && <RidersSummaryReport fromdate={fromdate} todate={todate} tenantId={tenantId} />}
|
||||||
{tab === 'orders-details' && <OrdersDetailsReport fromdate={fromdate} todate={todate} searchQuery={searchQuery} />}
|
|
||||||
{tab === 'maps' && (
|
|
||||||
<div className="bg-white border rounded-2xl p-4" style={{ borderColor: BORDER }}>
|
|
||||||
<span className="text-[10px] font-extrabold uppercase tracking-widest flex items-center gap-1.5 mb-2" style={{ color: TEXT_2 }}><Route size={12} /> Planned routes & live rider logs</span>
|
|
||||||
<AwaitingApi label="Rider route maps & live location logs" api="maps + rider telemetry" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -115,8 +104,8 @@ function TableShell({ minWidth, head, children, footer }: { minWidth: number; he
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Orders Summary (per outlet) ──────────────────────────────────────────────────
|
// ── Orders Summary (per outlet) ──────────────────────────────────────────────────
|
||||||
function OrdersSummaryReport() {
|
function OrdersSummaryReport({ tenantId }: { tenantId: number }) {
|
||||||
const q = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
const q = useFiestaLocationSummary(tenantId);
|
||||||
const rows = q.data ?? [];
|
const rows = q.data ?? [];
|
||||||
const totals = rows.reduce((a, r) => ({ total: a.total + r.total, pending: a.pending + r.pending, delivered: a.delivered + r.delivered, cancelled: a.cancelled + r.cancelled }), { total: 0, pending: 0, delivered: 0, cancelled: 0 });
|
const totals = rows.reduce((a, r) => ({ total: a.total + r.total, pending: a.pending + r.pending, delivered: a.delivered + r.delivered, cancelled: a.cancelled + r.cancelled }), { total: 0, pending: 0, delivered: 0, cancelled: 0 });
|
||||||
const kpis = [
|
const kpis = [
|
||||||
@@ -150,8 +139,8 @@ function OrdersSummaryReport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Riders Summary (per rider) ───────────────────────────────────────────────────
|
// ── Riders Summary (per rider) ───────────────────────────────────────────────────
|
||||||
function RidersSummaryReport({ fromdate, todate }: { fromdate: string; todate: string }) {
|
function RidersSummaryReport({ fromdate, todate, tenantId }: { fromdate: string; todate: string; tenantId: number }) {
|
||||||
const q = useFiestaFleetSummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
const q = useFiestaFleetSummary({ tenantid: tenantId, fromdate, todate });
|
||||||
const rows = q.data ?? [];
|
const rows = q.data ?? [];
|
||||||
const mapped = rows.map((r) => ({
|
const mapped = rows.map((r) => ({
|
||||||
name: fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() || fstr(r.username) || `Rider ${fstr(r.userid)}`,
|
name: fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() || fstr(r.username) || `Rider ${fstr(r.userid)}`,
|
||||||
@@ -196,97 +185,7 @@ function RidersSummaryReport({ fromdate, todate }: { fromdate: string; todate: s
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Orders Details (line-level + CSV) ────────────────────────────────────────────
|
|
||||||
const DETAIL_STATUSES = ['all', 'pending', 'accepted', 'arrived', 'picked', 'active', 'delivered', 'skipped', 'cancelled'] as const;
|
|
||||||
type DetailStatus = (typeof DETAIL_STATUSES)[number];
|
|
||||||
|
|
||||||
function OrdersDetailsReport({ fromdate, todate, searchQuery }: { fromdate: string; todate: string; searchQuery: string }) {
|
|
||||||
const q = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
|
||||||
const allRows = q.data ?? [];
|
|
||||||
const [status, setStatus] = useState<DetailStatus>('all');
|
|
||||||
const [localSearch, setLocalSearch] = useState('');
|
|
||||||
|
|
||||||
const statusCounts = useMemo(() => {
|
|
||||||
const acc: Record<string, number> = {};
|
|
||||||
for (const r of allRows) { const s = fstr(r.orderstatus).toLowerCase(); acc[s] = (acc[s] ?? 0) + 1; }
|
|
||||||
return acc;
|
|
||||||
}, [allRows]);
|
|
||||||
const rows = useMemo(() => {
|
|
||||||
const term = (localSearch || searchQuery).toLowerCase();
|
|
||||||
return allRows.filter((r) => {
|
|
||||||
if (status !== 'all' && fstr(r.orderstatus).toLowerCase() !== status) return false;
|
|
||||||
if (!term) return true;
|
|
||||||
return [r.orderid, r.deliverycustomer, r.deliveryaddress, r.ridername].some((f) => fstr(f).toLowerCase().includes(term));
|
|
||||||
});
|
|
||||||
}, [allRows, status, localSearch, searchQuery]);
|
|
||||||
|
|
||||||
const exportCsv = () => {
|
|
||||||
const headers = ['Order ID', 'Status', 'Rider', 'Customer', 'Suburb', 'Address', 'Assigned', 'Delivered', 'KMs', 'Actual KMs', 'Charges', 'Amount'];
|
|
||||||
const esc = (v: unknown) => `"${fstr(v).replace(/"/g, '""')}"`;
|
|
||||||
const lines = rows.map((r) => [r.orderid, r.orderstatus, fstr(r.ridername) || fstr(r.username), r.deliverycustomer, r.deliverysuburb, r.deliveryaddress, shortTime(r.assigntime), shortTime(r.deliverytime), fnum(r.kms), fnum(r.cumulativekms), fnum(r.deliverycharges), fnum(r.deliveryamt)].map(esc).join(','));
|
|
||||||
const blob = new Blob([[headers.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a'); a.href = url; a.download = `Orders_Detail_${fromdate}_to_${todate}.csv`; a.click(); URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<FilterBar>
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
|
|
||||||
<div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0">
|
|
||||||
{DETAIL_STATUSES.map((s) => {
|
|
||||||
const color = s === 'all' ? BRAND : statusColor(DELIVERY_STATUS, s);
|
|
||||||
return (
|
|
||||||
<React.Fragment key={s}>
|
|
||||||
<Pill active={status === s} color={color} onClick={() => setStatus(s)} count={s === 'all' ? allRows.length : statusCounts[s] ?? 0}>
|
|
||||||
<span className="capitalize">{s}</span>
|
|
||||||
</Pill>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 lg:shrink-0">
|
|
||||||
<div className="w-full lg:w-56"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search…" /></div>
|
|
||||||
<button onClick={exportCsv} disabled={rows.length === 0} className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
|
|
||||||
style={{ padding: '7px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}>
|
|
||||||
<Download size={13} /> CSV
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FilterBar>
|
|
||||||
|
|
||||||
<TableShell minWidth={1040} head={['#', 'Order', 'Drop', 'Rider', 'Assigned', 'Delivered', 'KMs', 'Charges', 'Status']}
|
|
||||||
footer={<div className="px-4 py-2.5 border-t text-[10px] font-bold uppercase tracking-wider" style={{ borderColor: BORDER, background: SURFACE_ALT, color: TEXT_2 }}>{rows.length} rows · {fromdate} → {todate}</div>}>
|
|
||||||
{q.isLoading ? <tr><td colSpan={9} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>Loading order details…</td></tr>
|
|
||||||
: rows.length === 0 ? <tr><td colSpan={9} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No deliveries match this filter.</td></tr>
|
|
||||||
: rows.map((r, i) => {
|
|
||||||
const st = fstr(r.orderstatus).toLowerCase();
|
|
||||||
const rider = fstr(r.ridername) || fstr(r.username);
|
|
||||||
const charge = fnum(r.deliverycharges) || fnum(r.deliveryamt);
|
|
||||||
return (
|
|
||||||
<tr key={fstr(r.deliveryid) || fstr(r.orderid) || i} className="transition-colors align-top" style={{ borderBottom: `1px solid ${DIVIDER}` }} onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
|
|
||||||
<td className="px-3 py-2.5 font-mono text-left" style={{ color: TEXT_3 }}>{i + 1}</td>
|
|
||||||
<td className="px-3 py-2.5 text-left">
|
|
||||||
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}</p>
|
|
||||||
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.deliverydate || r.assigntime)}</p>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 text-left">
|
|
||||||
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
|
|
||||||
<p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 text-right font-medium text-xs truncate max-w-[110px]" style={{ color: TEXT_2 }}>{rider || '—'}</td>
|
|
||||||
<td className="px-3 py-2.5 text-right font-mono text-xs" style={{ color: TEXT_2 }}>{shortTime(r.assigntime) || '—'}</td>
|
|
||||||
<td className="px-3 py-2.5 text-right font-mono text-xs" style={{ color: TEXT_2 }}>{fstr(r.deliverytime) ? shortTime(r.deliverytime) : '—'}</td>
|
|
||||||
<td className="px-3 py-2.5 text-right">{fnum(r.kms) ? <MetricPill color="#ef4444" minWidth={52}>{fnum(r.kms).toFixed(1)}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
|
||||||
<td className="px-3 py-2.5 text-right">{charge > 0 ? <MetricPill color="#10b981" minWidth={64}>₹{charge.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
|
||||||
<td className="px-3 py-2.5 text-right"><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableShell>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Total bar (gradient) ─────────────────────────────────────────────────────────
|
// ── Total bar (gradient) ─────────────────────────────────────────────────────────
|
||||||
function TotalBar({ chips, grand }: { chips: Array<{ label: string; color: string }>; grand?: string }) {
|
function TotalBar({ chips, grand }: { chips: Array<{ label: string; color: string }>; grand?: string }) {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
* Dispatch page — a faithful port of the operations console's dispatch cockpit
|
* Dispatch page — a faithful port of the operations console's dispatch cockpit
|
||||||
* (nearle_console/dispatch). It reuses that page's actual stylesheet verbatim
|
* (nearle_console/dispatch). It reuses that page's actual stylesheet verbatim
|
||||||
* (`./DispatchView.css`, copied from Dispatch.css) and reproduces the same DOM /
|
* (`./DispatchView.css`, copied from Dispatch.css) and reproduces the same DOM /
|
||||||
* class structure: the `#hdr` bar, `#strat-row` view tabs, `#batch-row` wave
|
* class structure: the `#hdr` bar, `#strat-row` view tabs, the 400px `#sidebar`
|
||||||
* selector, the 400px `#sidebar` (RIDER DISPATCH header + KPI tiles + rider/zone
|
* (RIDER DISPATCH header + KPI tiles + rider/zone cards + per-trip order cards),
|
||||||
* cards + per-trip order cards), and the `#map-wrap` centrepiece.
|
* and the `#map-wrap` centrepiece.
|
||||||
*
|
*
|
||||||
* The source map is a Leaflet canvas of planned-vs-actual rider routes (OSRM
|
* The source map is a Leaflet canvas of planned-vs-actual rider routes (OSRM
|
||||||
* road-snapping, Kalman-smoothed GPS) plus AI rider-assignment posting to
|
* road-snapping, Kalman-smoothed GPS) plus AI rider-assignment posting to
|
||||||
@@ -24,8 +24,8 @@ import {
|
|||||||
Map as MapIcon,
|
Map as MapIcon,
|
||||||
MapPin,
|
MapPin,
|
||||||
Bike,
|
Bike,
|
||||||
Globe,
|
ShoppingBag,
|
||||||
Info,
|
Truck,
|
||||||
Package,
|
Package,
|
||||||
Ruler,
|
Ruler,
|
||||||
Wallet,
|
Wallet,
|
||||||
@@ -40,11 +40,9 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
List,
|
List,
|
||||||
Play,
|
Play,
|
||||||
PlugZap,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useFiestaDeliveries, useFiestaRiders } from '../services/fiestaQueries';
|
import { useFiestaDeliveries, useFiestaRiders } from '../services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||||
import { MOCK_DELIVERIES, MOCK_RIDERS } from '../services/dispatchMockData';
|
|
||||||
import DispatchMap, { type MapPoint } from './DispatchMap';
|
import DispatchMap, { type MapPoint } from './DispatchMap';
|
||||||
import './DispatchView.css';
|
import './DispatchView.css';
|
||||||
|
|
||||||
@@ -86,45 +84,14 @@ function pickupLatLon(r: Row): [number, number] | null {
|
|||||||
return lat && lon ? [lat, lon] : null;
|
return lat && lon ? [lat, lon] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Batch / wave model (canonical half-open hour ranges, local time) ─────────────
|
|
||||||
// Mirrors Dispatch.js BATCH_OPTIONS: gaps (8–9, 12:30–16, after 19) are intentional.
|
|
||||||
type BatchId = 'all' | 'morning' | 'afternoon' | 'evening';
|
|
||||||
const BATCHES: Array<{ id: BatchId; label: string; range: string }> = [
|
|
||||||
{ id: 'all', label: 'All', range: 'Full day' },
|
|
||||||
{ id: 'morning', label: 'Morning', range: '12 AM – 8 AM' },
|
|
||||||
{ id: 'afternoon', label: 'Afternoon', range: '9 AM – 12:30 PM' },
|
|
||||||
{ id: 'evening', label: 'Evening', range: '4 PM – 7 PM' },
|
|
||||||
];
|
|
||||||
function rowHourFrac(r: Row): number | null {
|
|
||||||
const raw = fstr(r.assigntime) || fstr(r.deliverytime) || fstr(r.deliverydate);
|
|
||||||
const m = raw.match(/[ T](\d{1,2}):(\d{2})/);
|
|
||||||
if (!m) return null;
|
|
||||||
return Number(m[1]) + Number(m[2]) / 60;
|
|
||||||
}
|
|
||||||
function inBatch(r: Row, b: BatchId): boolean {
|
|
||||||
if (b === 'all') return true;
|
|
||||||
const h = rowHourFrac(r);
|
|
||||||
if (h == null) return false;
|
|
||||||
if (b === 'morning') return h >= 0 && h < 8;
|
|
||||||
if (b === 'afternoon') return h >= 9 && h < 12.5;
|
|
||||||
return h >= 16 && h < 19; // evening
|
|
||||||
}
|
|
||||||
function initialBatch(): BatchId {
|
|
||||||
const h = new Date().getHours();
|
|
||||||
if (h >= 0 && h < 8) return 'morning';
|
|
||||||
if (h >= 9 && h < 12.5) return 'afternoon';
|
|
||||||
if (h >= 16 && h < 19) return 'evening';
|
|
||||||
return 'all';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── View modes (match #strat-row tabs) ───────────────────────────────────────────
|
// ── View modes (match #strat-row tabs) ───────────────────────────────────────────
|
||||||
type ViewMode = 'kitchens' | 'zones' | 'riders' | 'all' | 'rider-info';
|
type ViewMode = 'kitchens' | 'zones' | 'riders' | 'orders' | 'deliveries';
|
||||||
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [
|
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [
|
||||||
{ id: 'kitchens', label: 'By Location', icon: MapPin },
|
{ id: 'kitchens', label: 'By Location', icon: MapPin },
|
||||||
{ id: 'zones', label: 'By Zone', icon: MapIcon },
|
{ id: 'zones', label: 'By Zone', icon: MapIcon },
|
||||||
{ id: 'riders', label: 'By Rider', icon: Bike },
|
{ id: 'riders', label: 'By Rider', icon: Bike },
|
||||||
{ id: 'all', label: 'All Routes', icon: Globe },
|
{ id: 'orders', label: 'By Orders', icon: ShoppingBag },
|
||||||
{ id: 'rider-info', label: 'Rider Info', icon: Info },
|
{ id: 'deliveries', label: 'By Deliveries', icon: Truck },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Group {
|
interface Group {
|
||||||
@@ -142,15 +109,15 @@ interface Group {
|
|||||||
|
|
||||||
interface DispatchViewProps {
|
interface DispatchViewProps {
|
||||||
locationid?: number;
|
locationid?: number;
|
||||||
|
tenantId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
export default function DispatchView({ locationid }: DispatchViewProps) {
|
export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID }: DispatchViewProps) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const [date, setDate] = useState<string>(ymd(today));
|
const [date, setDate] = useState<string>(ymd(today));
|
||||||
const [batch, setBatch] = useState<BatchId>(initialBatch());
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('riders');
|
const [viewMode, setViewMode] = useState<ViewMode>('riders');
|
||||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
@@ -158,37 +125,26 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
|
|||||||
const [animateNonce, setAnimateNonce] = useState(0);
|
const [animateNonce, setAnimateNonce] = useState(0);
|
||||||
const [animating, setAnimating] = useState(false);
|
const [animating, setAnimating] = useState(false);
|
||||||
|
|
||||||
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate: date, todate: date });
|
const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid });
|
||||||
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
|
const ridersQ = useFiestaRiders({ tenantid: tenantId });
|
||||||
|
|
||||||
// Sample-data fallback: when the live feed returns nothing, render the demo set
|
// Live deliveries only — no sample/demo fallback. When the feed is empty the
|
||||||
// so the cockpit isn't blank. The header labels it "Sample data" so it's never
|
// cockpit shows a genuine empty state rather than fabricated riders/stops.
|
||||||
// mistaken for live (see services/dispatchMockData.ts).
|
const allRows = deliveriesQ.data ?? [];
|
||||||
const liveRows = deliveriesQ.data ?? [];
|
const inScope = (r: Row) => !locationid || fnum(r.locationid) === locationid;
|
||||||
const usingMock = !deliveriesQ.isLoading && !deliveriesQ.isError && liveRows.length === 0;
|
|
||||||
const allRows = usingMock ? MOCK_DELIVERIES : liveRows;
|
|
||||||
// Sample rows aren't tied to the signed-in store, so skip the outlet filter for them.
|
|
||||||
const inScope = (r: Row) => usingMock || !locationid || fnum(r.locationid) === locationid;
|
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() => allRows.filter((r) => inScope(r) && inBatch(r, batch)),
|
() => allRows.filter(inScope),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[allRows, batch, locationid, usingMock],
|
[allRows, locationid],
|
||||||
);
|
);
|
||||||
|
|
||||||
const batchCounts = useMemo(() => {
|
|
||||||
const acc: Record<string, number> = { all: 0, morning: 0, afternoon: 0, evening: 0 };
|
|
||||||
const scoped = allRows.filter(inScope);
|
|
||||||
for (const b of BATCHES) acc[b.id] = scoped.filter((r) => inBatch(r, b.id)).length;
|
|
||||||
return acc;
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [allRows, locationid, usingMock]);
|
|
||||||
|
|
||||||
// ── Grouping ────────────────────────────────────────────────────────────────
|
// ── Grouping ────────────────────────────────────────────────────────────────
|
||||||
const groups = useMemo<Group[]>(() => {
|
const groups = useMemo<Group[]>(() => {
|
||||||
const map = new Map<string, Group>();
|
const map = new Map<string, Group>();
|
||||||
|
const titleCase = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
||||||
const keyOf = (r: Row): { id: string; name: string } => {
|
const keyOf = (r: Row): { id: string; name: string } => {
|
||||||
if (viewMode === 'riders' || viewMode === 'rider-info') {
|
if (viewMode === 'riders') {
|
||||||
const id = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
|
const id = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
|
||||||
return { id, name: fstr(r.ridername) || fstr(r.username) || (id === 'unassigned' ? 'Unassigned' : `Rider ${id}`) };
|
return { id, name: fstr(r.ridername) || fstr(r.username) || (id === 'unassigned' ? 'Unassigned' : `Rider ${id}`) };
|
||||||
}
|
}
|
||||||
@@ -196,7 +152,16 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
|
|||||||
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || 'Pickup';
|
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || 'Pickup';
|
||||||
return { id: name.toLowerCase(), name };
|
return { id: name.toLowerCase(), name };
|
||||||
}
|
}
|
||||||
if (viewMode === 'all') return { id: 'all', name: 'All Routes' };
|
if (viewMode === 'orders') {
|
||||||
|
// Bucket by ORDER status (created / pending / processing / delivered / cancelled).
|
||||||
|
const s = fstr(r.orderstatus).toLowerCase() || 'unknown';
|
||||||
|
return { id: `o:${s}`, name: titleCase(s) };
|
||||||
|
}
|
||||||
|
if (viewMode === 'deliveries') {
|
||||||
|
// Bucket by DELIVERY/dispatch status (falls back to order status, then unassigned).
|
||||||
|
const s = (fstr(r.deliverystatus) || fstr(r.orderstatus)).toLowerCase() || 'unassigned';
|
||||||
|
return { id: `d:${s}`, name: titleCase(s) };
|
||||||
|
}
|
||||||
const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
|
const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
|
||||||
return { id: name.toLowerCase(), name };
|
return { id: name.toLowerCase(), name };
|
||||||
};
|
};
|
||||||
@@ -222,7 +187,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
|
|||||||
}, [rows, viewMode]);
|
}, [rows, viewMode]);
|
||||||
|
|
||||||
const focused = groups.find((g) => g.id === focusedId) ?? null;
|
const focused = groups.find((g) => g.id === focusedId) ?? null;
|
||||||
const groupedByRider = viewMode === 'zones' || viewMode === 'kitchens' || viewMode === 'all';
|
const groupedByRider = viewMode !== 'riders';
|
||||||
|
|
||||||
// Trip blocks for the focused group: by trip# (rider view) or by rider (zone/all view).
|
// Trip blocks for the focused group: by trip# (rider view) or by rider (zone/all view).
|
||||||
const tripBlocks = useMemo(() => {
|
const tripBlocks = useMemo(() => {
|
||||||
@@ -264,7 +229,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
|
|||||||
}, [focused, groupedByRider, tripSort]);
|
}, [focused, groupedByRider, tripSort]);
|
||||||
|
|
||||||
// Map points: the focused group's ordered stops (with a route), else every stop
|
// Map points: the focused group's ordered stops (with a route), else every stop
|
||||||
// in the wave (coloured per rider). Rows without coordinates are skipped.
|
// for the day (coloured per rider). Rows without coordinates are skipped.
|
||||||
const mapPoints = useMemo<MapPoint[]>(() => {
|
const mapPoints = useMemo<MapPoint[]>(() => {
|
||||||
const src = focused ? tripBlocks.flatMap((b) => b.orders) : rows;
|
const src = focused ? tripBlocks.flatMap((b) => b.orders) : rows;
|
||||||
const out: MapPoint[] = [];
|
const out: MapPoint[] = [];
|
||||||
@@ -293,8 +258,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
|
|||||||
// KPI scope.
|
// KPI scope.
|
||||||
const totalOrders = rows.length;
|
const totalOrders = rows.length;
|
||||||
const activeRiders = new Set(rows.map((r) => fstr(r.userid) || fstr(r.ridername)).filter(Boolean)).size;
|
const activeRiders = new Set(rows.map((r) => fstr(r.userid) || fstr(r.ridername)).filter(Boolean)).size;
|
||||||
const fleetSize = usingMock ? MOCK_RIDERS.length : (ridersQ.data ?? []).length;
|
const fleetSize = (ridersQ.data ?? []).length;
|
||||||
const scopeLabel = BATCHES.find((b) => b.id === batch)?.label ?? 'All';
|
|
||||||
|
|
||||||
// Date chip helpers.
|
// Date chip helpers.
|
||||||
const isToday = date === ymd(today);
|
const isToday = date === ymd(today);
|
||||||
@@ -338,9 +302,9 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
|
|||||||
<span className="live-status live-status-error">
|
<span className="live-status live-status-error">
|
||||||
<span className="live-dot error" /> Offline
|
<span className="live-dot error" /> Offline
|
||||||
</span>
|
</span>
|
||||||
) : usingMock ? (
|
) : totalOrders === 0 ? (
|
||||||
<span className="live-status" title="No live deliveries for this day — showing sample data">
|
<span className="live-status" title="No deliveries dispatched for this day">
|
||||||
<span className="live-dot" style={{ background: '#f59e0b' }} /> Sample data · {totalOrders} orders
|
<span className="live-dot" style={{ background: '#94a3b8' }} /> No deliveries today
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="live-status live-status-ready">
|
<span className="live-status live-status-ready">
|
||||||
@@ -383,7 +347,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className={`sbt ${viewMode === t.id ? 'active' : ''}${t.id === 'rider-info' ? ' sbt-rider-info' : ''}`}
|
className={`sbt ${viewMode === t.id ? 'active' : ''}`}
|
||||||
onClick={() => { setViewMode(t.id); setFocusedId(null); }}
|
onClick={() => { setViewMode(t.id); setFocusedId(null); }}
|
||||||
>
|
>
|
||||||
<span className="sbt-icon"><Icon size={15} /></span>
|
<span className="sbt-icon"><Icon size={15} /></span>
|
||||||
@@ -393,24 +357,6 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Batch / wave bar ── */}
|
|
||||||
<div id="batch-row">
|
|
||||||
<span className="batch-label">Batch</span>
|
|
||||||
<div className="batch-scroll">
|
|
||||||
{BATCHES.map((b) => (
|
|
||||||
<button
|
|
||||||
key={b.id}
|
|
||||||
className={`batch-btn batch-slot ${batch === b.id ? 'active' : ''}`}
|
|
||||||
onClick={() => { setBatch(b.id); setFocusedId(null); }}
|
|
||||||
title={`${b.label} (${b.range})`}
|
|
||||||
>
|
|
||||||
<span className="batch-btn-label">{b.label}</span>
|
|
||||||
<span className="batch-btn-count">{batchCounts[b.id] ?? 0}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Body ── */}
|
{/* ── Body ── */}
|
||||||
<div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}>
|
<div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}>
|
||||||
<button
|
<button
|
||||||
@@ -431,7 +377,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
<span className="sb-header-scope">
|
<span className="sb-header-scope">
|
||||||
<span className="sb-scope-dot" />
|
<span className="sb-scope-dot" />
|
||||||
{scopeLabel}
|
{totalOrders} stops
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="sb-header-tiles">
|
<div className="sb-header-tiles">
|
||||||
@@ -466,15 +412,15 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
|
|||||||
fmtTime={fmtTime}
|
fmtTime={fmtTime}
|
||||||
/>
|
/>
|
||||||
) : groups.length === 0 ? (
|
) : groups.length === 0 ? (
|
||||||
<div className="ph">No deliveries in this wave</div>
|
<div className="ph">No deliveries for this day</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="ph">
|
<div className="ph">
|
||||||
{viewMode === 'riders' || viewMode === 'rider-info' ? 'Riders' : viewMode === 'kitchens' ? 'Pickup points' : viewMode === 'all' ? 'All routes' : 'Zones'} ({groups.length})
|
{viewMode === 'riders' ? 'Riders' : viewMode === 'kitchens' ? 'Pickup points' : viewMode === 'orders' ? 'Order statuses' : viewMode === 'deliveries' ? 'Delivery statuses' : 'Zones'} ({groups.length})
|
||||||
</div>
|
</div>
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<React.Fragment key={g.id}>
|
<React.Fragment key={g.id}>
|
||||||
{viewMode === 'riders' || viewMode === 'rider-info'
|
{viewMode === 'riders'
|
||||||
? <RiderCard g={g} onClick={() => setFocusedId(g.id)} />
|
? <RiderCard g={g} onClick={() => setFocusedId(g.id)} />
|
||||||
: <ZoneCard g={g} onClick={() => setFocusedId(g.id)} />}
|
: <ZoneCard g={g} onClick={() => setFocusedId(g.id)} />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
@@ -492,22 +438,18 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
|
|||||||
route={Boolean(focused)}
|
route={Boolean(focused)}
|
||||||
routeColor={focused?.color || '#581c87'}
|
routeColor={focused?.color || '#581c87'}
|
||||||
start={routeStart}
|
start={routeStart}
|
||||||
resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}|${batch}`}
|
resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}`}
|
||||||
animateNonce={animateNonce}
|
animateNonce={animateNonce}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Contextual note overlaid on the map */}
|
{/* Contextual note overlaid on the map */}
|
||||||
{viewMode === 'rider-info' ? (
|
{mapPoints.length === 0 ? (
|
||||||
<div className="dmp-overlay-note">
|
<div className="dmp-overlay-note">
|
||||||
<PlugZap size={13} /> Live rider telemetry (battery · GPS · speed) awaiting backend — map shows planned drops.
|
<MapIcon size={13} /> No drop coordinates in {focused ? 'this route' : 'these deliveries'} yet.
|
||||||
</div>
|
|
||||||
) : mapPoints.length === 0 ? (
|
|
||||||
<div className="dmp-overlay-note">
|
|
||||||
<MapIcon size={13} /> No drop coordinates in this {focused ? 'route' : 'wave'} yet.
|
|
||||||
</div>
|
</div>
|
||||||
) : !focused ? (
|
) : !focused ? (
|
||||||
<div className="dmp-overlay-note">
|
<div className="dmp-overlay-note">
|
||||||
<MapIcon size={13} /> Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : 'rider'} to draw its route.
|
<MapIcon size={13} /> Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : viewMode === 'riders' ? 'rider' : 'group'} to draw its route.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Menu, HelpCircle, LogOut, ChevronDown, Mail } from 'lucide-react';
|
import { Menu, HelpCircle, LogOut, ChevronDown, Mail, QrCode, User } from 'lucide-react';
|
||||||
import { MainSection } from '../types';
|
import { MainSection } from '../types';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
@@ -17,6 +17,10 @@ interface HeaderProps {
|
|||||||
isSidebarOpen: boolean;
|
isSidebarOpen: boolean;
|
||||||
onHelpClick: () => void;
|
onHelpClick: () => void;
|
||||||
onLogoutClick: () => void;
|
onLogoutClick: () => void;
|
||||||
|
/** When provided, shows a "My Account" item in the profile dropdown (user store page). */
|
||||||
|
onAccountClick?: () => void;
|
||||||
|
/** When provided, shows a Store QR button on the right of the navbar (user store page). */
|
||||||
|
onQrClick?: () => void;
|
||||||
/** Signed-in user shown in the profile dropdown. */
|
/** Signed-in user shown in the profile dropdown. */
|
||||||
profile: { name: string; role: string; email: string };
|
profile: { name: string; role: string; email: string };
|
||||||
}
|
}
|
||||||
@@ -26,6 +30,8 @@ export default function Header({
|
|||||||
isSidebarOpen,
|
isSidebarOpen,
|
||||||
onHelpClick,
|
onHelpClick,
|
||||||
onLogoutClick,
|
onLogoutClick,
|
||||||
|
onAccountClick,
|
||||||
|
onQrClick,
|
||||||
profile
|
profile
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const [showProfileDropdown, setShowProfileDropdown] = useState(false);
|
const [showProfileDropdown, setShowProfileDropdown] = useState(false);
|
||||||
@@ -81,6 +87,18 @@ export default function Header({
|
|||||||
|
|
||||||
{/* Global Actions Bar */}
|
{/* Global Actions Bar */}
|
||||||
<div className="flex items-center gap-md">
|
<div className="flex items-center gap-md">
|
||||||
|
{/* Store QR — opens the QR modal (user store page only) */}
|
||||||
|
{onQrClick && (
|
||||||
|
<button
|
||||||
|
onClick={onQrClick}
|
||||||
|
title="Store QR code"
|
||||||
|
aria-label="Store QR code"
|
||||||
|
className="p-2 rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white"
|
||||||
|
>
|
||||||
|
<QrCode size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* User profile with dropdown */}
|
{/* User profile with dropdown */}
|
||||||
<div className="relative" ref={profileRef}>
|
<div className="relative" ref={profileRef}>
|
||||||
<button
|
<button
|
||||||
@@ -134,6 +152,17 @@ export default function Header({
|
|||||||
|
|
||||||
{/* Account actions (moved here from the sidebar) */}
|
{/* Account actions (moved here from the sidebar) */}
|
||||||
<div className="p-2 flex flex-col gap-0.5">
|
<div className="p-2 flex flex-col gap-0.5">
|
||||||
|
{onAccountClick && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowProfileDropdown(false); onAccountClick(); }}
|
||||||
|
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"
|
||||||
|
>
|
||||||
|
<span className="h-7 w-7 rounded-lg bg-purple-50 text-[#581c87] ring-1 ring-purple-100 flex items-center justify-center group-hover/item:scale-110 transition-transform">
|
||||||
|
<User size={14} />
|
||||||
|
</span>
|
||||||
|
My Account
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowProfileDropdown(false); onHelpClick(); }}
|
onClick={() => { setShowProfileDropdown(false); onHelpClick(); }}
|
||||||
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"
|
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
} from '../services/fiestaQueries';
|
} from '../services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi';
|
||||||
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
||||||
|
import { useStoreCatalogue } from '../services/storeCatalogue';
|
||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
|
|
||||||
type StockRow = Record<string, unknown>;
|
type StockRow = Record<string, unknown>;
|
||||||
@@ -46,18 +47,20 @@ const rowId = (r: StockRow) => String(r.productid ?? '') || String(r.productname
|
|||||||
interface InventoryViewProps {
|
interface InventoryViewProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
isCoimbatoreView: boolean;
|
isCoimbatoreView: boolean;
|
||||||
|
tenantId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InventoryView({
|
export default function InventoryView({
|
||||||
searchQuery,
|
searchQuery,
|
||||||
isCoimbatoreView
|
isCoimbatoreView,
|
||||||
|
tenantId = FIESTA_TENANT_ID
|
||||||
}: InventoryViewProps) {
|
}: InventoryViewProps) {
|
||||||
// ── Live stock across every outlet (Fiesta) ───────────────────────────────
|
// ── Live stock across every outlet (Fiesta) ───────────────────────────────
|
||||||
// This page is the admin's command surface. The GLOBAL CATALOG is the deduped
|
// This page is the admin's command surface. The GLOBAL CATALOG is the deduped
|
||||||
// union of products across all outlets the tenant owns (admin-only import adds
|
// union of products across all outlets the tenant owns (admin-only import adds
|
||||||
// to it); the STORE STOCK section shows each outlet's live stock so the admin
|
// to it); the STORE STOCK section shows each outlet's live stock so the admin
|
||||||
// can see all the stores under them at a glance.
|
// can see all the stores under them at a glance.
|
||||||
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
const locationsQ = useFiestaTenantLocations(tenantId);
|
||||||
const locations = useMemo(
|
const locations = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(locationsQ.data ?? []).map((l) => ({
|
(locationsQ.data ?? []).map((l) => ({
|
||||||
@@ -69,7 +72,7 @@ export default function InventoryView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const storesStock = useFiestaStoresStock(
|
const storesStock = useFiestaStoresStock(
|
||||||
FIESTA_TENANT_ID,
|
tenantId,
|
||||||
locations.map(({ locationid, locationname }) => ({ locationid, locationname })),
|
locations.map(({ locationid, locationname }) => ({ locationid, locationname })),
|
||||||
);
|
);
|
||||||
const storesLoading = locationsQ.isLoading || storesStock.some((s) => s.isLoading);
|
const storesLoading = locationsQ.isLoading || storesStock.some((s) => s.isLoading);
|
||||||
@@ -95,6 +98,8 @@ export default function InventoryView({
|
|||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'catalog' | 'import_branding'>('catalog');
|
const [activeTab, setActiveTab] = useState<'catalog' | 'import_branding'>('catalog');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('ALL');
|
const [selectedCategory, setSelectedCategory] = useState<string>('ALL');
|
||||||
|
// The store catalogue the admin curates from the global catalogue (shown to users).
|
||||||
|
const storeCat = useStoreCatalogue();
|
||||||
const [showAddProductModal, setShowAddProductModal] = useState(false);
|
const [showAddProductModal, setShowAddProductModal] = useState(false);
|
||||||
const [outletFilter, setOutletFilter] = useState<'all' | 'alerts'>('all');
|
const [outletFilter, setOutletFilter] = useState<'all' | 'alerts'>('all');
|
||||||
const [outletSearch, setOutletSearch] = useState('');
|
const [outletSearch, setOutletSearch] = useState('');
|
||||||
@@ -197,7 +202,7 @@ export default function InventoryView({
|
|||||||
const handleAddNewProduct = (e: React.FormEvent) => {
|
const handleAddNewProduct = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newProduct.name || !newProduct.sku) {
|
if (!newProduct.name || !newProduct.sku) {
|
||||||
alert('Kindly supply correct product specifications and catalog SKU code.');
|
alert('Kindly supply correct product specifications and catalogue SKU code.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +222,7 @@ export default function InventoryView({
|
|||||||
|
|
||||||
setProducts([createdProd, ...products]);
|
setProducts([createdProd, ...products]);
|
||||||
setShowAddProductModal(false);
|
setShowAddProductModal(false);
|
||||||
alert(`Fresh product "${createdProd.name}" added to the Global Catalog. It is now available to roll out to all outlets.`);
|
alert(`Fresh product "${createdProd.name}" added to the Global Catalogue. It is now available to roll out to all outlets.`);
|
||||||
|
|
||||||
setNewProduct({
|
setNewProduct({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -274,9 +279,9 @@ export default function InventoryView({
|
|||||||
|
|
||||||
if (parsedCount > 0) {
|
if (parsedCount > 0) {
|
||||||
setProducts(prev => [...newProds, ...prev]);
|
setProducts(prev => [...newProds, ...prev]);
|
||||||
alert(`Synchronized ${parsedCount} regional products into Catalog database successfully!`);
|
alert(`Synchronized ${parsedCount} regional products into Catalogue database successfully!`);
|
||||||
} else {
|
} else {
|
||||||
alert('All the specified SKU codes are already active in the catalog ledger.');
|
alert('All the specified SKU codes are already active in the catalogue ledger.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -293,7 +298,7 @@ export default function InventoryView({
|
|||||||
<div className="absolute inset-0 z-0 overflow-hidden rounded-2xl">
|
<div className="absolute inset-0 z-0 overflow-hidden rounded-2xl">
|
||||||
<img
|
<img
|
||||||
src="https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=1200&q=80"
|
src="https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=1200&q=80"
|
||||||
alt="Catalog Command Center Banner"
|
alt="Catalogue Command Center Banner"
|
||||||
className="w-full h-full object-cover object-center opacity-30"
|
className="w-full h-full object-cover object-center opacity-30"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-900/95 to-purple-950/85" />
|
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-900/95 to-purple-950/85" />
|
||||||
@@ -308,13 +313,13 @@ export default function InventoryView({
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-white flex items-center gap-2">
|
<h1 className="font-sans font-bold text-2xl tracking-tight text-white flex items-center gap-2">
|
||||||
<Layers size={24} className="text-purple-300" />
|
<Layers size={24} className="text-purple-300" />
|
||||||
Product Catalog
|
Product Catalogue
|
||||||
<span className="text-[10px] text-purple-200 font-bold bg-purple-900/60 border border-purple-500/30 px-2 py-0.5 rounded-full uppercase tracking-wider animate-pulse">
|
<span className="text-[10px] text-purple-200 font-bold bg-purple-900/60 border border-purple-500/30 px-2 py-0.5 rounded-full uppercase tracking-wider animate-pulse">
|
||||||
Global Sync
|
Global Sync
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-purple-250 font-sans text-xs mt-1.5 font-medium max-w-2xl">
|
<p className="text-purple-250 font-sans text-xs mt-1.5 font-medium max-w-2xl">
|
||||||
Master catalog registry with regional assortment presets, brand styling studio, and live stock synchronization feeds.
|
Master catalogue registry with regional assortment presets, brand styling studio, and live stock synchronization feeds.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -329,7 +334,7 @@ export default function InventoryView({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Package size={13} />
|
<Package size={13} />
|
||||||
<span>Catalog & Stocks</span>
|
<span>Catalogue & Stocks</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -361,7 +366,7 @@ export default function InventoryView({
|
|||||||
<h3 className="text-xl font-extrabold tracking-tight font-mono">
|
<h3 className="text-xl font-extrabold tracking-tight font-mono">
|
||||||
{products.length}
|
{products.length}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] text-purple-400 font-semibold mt-1">Master catalog</p>
|
<p className="text-[10px] text-purple-400 font-semibold mt-1">Master catalogue</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -405,7 +410,7 @@ export default function InventoryView({
|
|||||||
{/* Card 4: Catalog Health */}
|
{/* Card 4: Catalog Health */}
|
||||||
<div className="bg-slate-900/40 backdrop-blur-sm rounded-xl p-4 border border-slate-800/80 hover:border-purple-500/30 transition-all group">
|
<div className="bg-slate-900/40 backdrop-blur-sm rounded-xl p-4 border border-slate-800/80 hover:border-purple-500/30 transition-all group">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-bold">Catalog Sync Ratio</span>
|
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-bold">Catalogue Sync Ratio</span>
|
||||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-110 transition-transform">
|
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-110 transition-transform">
|
||||||
<ShieldCheck className="w-4 h-4" />
|
<ShieldCheck className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
@@ -489,18 +494,26 @@ export default function InventoryView({
|
|||||||
{/* Global Catalog — master assortment grid (full width) */}
|
{/* Global Catalog — master assortment grid (full width) */}
|
||||||
<div className="bg-white/40 backdrop-blur-md border border-[#e2e8f0] p-md rounded-2xl text-xs font-sans shadow-sm">
|
<div className="bg-white/40 backdrop-blur-md border border-[#e2e8f0] p-md rounded-2xl text-xs font-sans shadow-sm">
|
||||||
<div className="flex items-center justify-between mb-sm">
|
<div className="flex items-center justify-between mb-sm">
|
||||||
|
<div>
|
||||||
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-1.5">
|
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-1.5">
|
||||||
<Package size={15} className="text-[#581c87]" /> Global Catalog Assortment
|
<Package size={15} className="text-[#581c87]" /> Global Catalogue Assortment
|
||||||
</h3>
|
</h3>
|
||||||
|
<p className="text-[10px] text-zinc-400 font-medium mt-0.5">Pick products & set quantities — selected items appear in every store's catalogue.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] text-emerald-700 font-bold bg-emerald-50 px-2 py-0.5 rounded-lg border border-emerald-100">
|
||||||
|
{storeCat.items.length} in store catalogue
|
||||||
|
</span>
|
||||||
<span className="text-[10px] text-[#581c87] font-bold bg-purple-50 px-2 py-0.5 rounded-lg border border-purple-100/50">
|
<span className="text-[10px] text-[#581c87] font-bold bg-purple-50 px-2 py-0.5 rounded-lg border border-purple-100/50">
|
||||||
{filteredProducts.length} item{filteredProducts.length === 1 ? '' : 's'} loaded
|
{filteredProducts.length} item{filteredProducts.length === 1 ? '' : 's'} loaded
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{storesLoading && products.length === 0 ? (
|
{storesLoading && products.length === 0 ? (
|
||||||
<div className="text-center py-xl text-zinc-400 text-xs font-bold">Synchronizing regional database...</div>
|
<div className="text-center py-xl text-zinc-400 text-xs font-bold">Synchronizing regional database...</div>
|
||||||
) : filteredProducts.length === 0 ? (
|
) : filteredProducts.length === 0 ? (
|
||||||
<div className="text-center py-xl text-zinc-400 text-xs font-bold">No catalog products match your selection.</div>
|
<div className="text-center py-xl text-zinc-400 text-xs font-bold">No catalogue products match your selection.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
|
||||||
{filteredProducts.map((prod) => (
|
{filteredProducts.map((prod) => (
|
||||||
@@ -565,6 +578,26 @@ export default function InventoryView({
|
|||||||
<div className="w-8 h-4.5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-350 after:border after:rounded-full after:h-3.5 after:w-3.5 after:transition-all peer-checked:bg-emerald-500 shadow-inner"></div>
|
<div className="w-8 h-4.5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-350 after:border after:rounded-full after:h-3.5 after:w-3.5 after:transition-all peer-checked:bg-emerald-500 shadow-inner"></div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Store-catalogue curation: pick the product + quantity to show to store users */}
|
||||||
|
{storeCat.has(prod.id) ? (
|
||||||
|
<div className="flex items-center justify-between gap-2 pt-2.5 border-t border-[#f1f5f9] mt-1">
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-emerald-600"><CheckCircle size={12} /> In Store Catalogue</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => storeCat.setQty(prod.id, storeCat.getQty(prod.id) - 1)} className="w-6 h-6 rounded-lg border border-[#e2e8f0] text-zinc-500 hover:bg-zinc-50 font-bold cursor-pointer leading-none">−</button>
|
||||||
|
<span className="w-8 text-center font-mono font-bold text-xs text-[#0f172a]">{storeCat.getQty(prod.id)}</span>
|
||||||
|
<button onClick={() => storeCat.setQty(prod.id, storeCat.getQty(prod.id) + 1)} className="w-6 h-6 rounded-lg border border-[#e2e8f0] text-zinc-500 hover:bg-zinc-50 font-bold cursor-pointer leading-none">+</button>
|
||||||
|
<button onClick={() => storeCat.remove(prod.id)} title="Remove from store catalogue" className="ml-1 w-6 h-6 rounded-lg text-rose-500 hover:bg-rose-50 flex items-center justify-center cursor-pointer"><X size={13} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => storeCat.add({ productid: prod.id, name: prod.name, image: prod.image, category: prod.category, sku: prod.sku, price: prod.unitsSold > 0 ? Math.round(prod.revenue / prod.unitsSold) : 0, unit: prod.exposure, qty: 1 })}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 pt-2.5 mt-1 border-t border-[#f1f5f9] text-[11px] font-bold text-[#581c87] hover:text-purple-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus size={13} /> Add to Store Catalogue
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -874,10 +907,10 @@ export default function InventoryView({
|
|||||||
<div className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] p-md rounded-2xl shadow-sm space-y-md">
|
<div className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] p-md rounded-2xl shadow-sm space-y-md">
|
||||||
<div className="flex items-center gap-xs text-[#0f172a] font-bold text-sm">
|
<div className="flex items-center gap-xs text-[#0f172a] font-bold text-sm">
|
||||||
<Sparkles className="text-amber-500 animate-pulse" size={18} />
|
<Sparkles className="text-amber-500 animate-pulse" size={18} />
|
||||||
<h3>Cooperative Catalog Presets</h3>
|
<h3>Cooperative Catalogue Presets</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AwaitingApi label="Catalog presets" api="[R5]" compact />
|
<AwaitingApi label="Catalogue presets" api="[R5]" compact />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom CSV Parsing Box */}
|
{/* Custom CSV Parsing Box */}
|
||||||
@@ -955,7 +988,7 @@ export default function InventoryView({
|
|||||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0">
|
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0">
|
||||||
<h4 className="font-bold text-[#0f172a] flex items-center gap-xs">
|
<h4 className="font-bold text-[#0f172a] flex items-center gap-xs">
|
||||||
<Package size={15} className="text-[#581c87]" />
|
<Package size={15} className="text-[#581c87]" />
|
||||||
Introduce New Grocery Catalog SKU
|
Introduce New Grocery Catalogue SKU
|
||||||
</h4>
|
</h4>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddProductModal(false)}
|
onClick={() => setShowAddProductModal(false)}
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const title = prompt('Enter product brand title:');
|
const title = prompt('Enter product brand title:');
|
||||||
const sku = prompt('Enter SKU catalog code:');
|
const sku = prompt('Enter SKU catalogue code:');
|
||||||
const category = prompt('Enter SKU Category:');
|
const category = prompt('Enter SKU Category:');
|
||||||
if (title && sku && category) {
|
if (title && sku && category) {
|
||||||
setProductList(prev => [
|
setProductList(prev => [
|
||||||
|
|||||||
@@ -3,502 +3,495 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
/**
|
||||||
|
* Orders & Deliveries view — embedded inside each store's detail panel.
|
||||||
|
* Rebuilt to match the same consoleUi design system used by OrdersView and
|
||||||
|
* DeliveriesView: gradient header, KPI strip with gradient top-bars, Pill
|
||||||
|
* filter tabs, status chips, full paginated data table, CSV export, and an
|
||||||
|
* order detail modal. Wired to the live Fiesta orders/getorders endpoint via
|
||||||
|
* useFiestaAllOrders (which merges all statuses in parallel).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
ShoppingBag,
|
ShoppingBag, Clock, CheckCircle2, XCircle, MapPin, Phone,
|
||||||
Truck,
|
Package, Loader2, X, Download, ChevronLeft, ChevronRight, Truck,
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
UserCheck,
|
UserCheck,
|
||||||
MapPin,
|
|
||||||
TrendingUp,
|
|
||||||
ChevronRight,
|
|
||||||
Package,
|
|
||||||
ArrowRight,
|
|
||||||
AlertCircle,
|
|
||||||
Clock4,
|
|
||||||
Search,
|
|
||||||
Check,
|
|
||||||
Calendar,
|
|
||||||
X
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { CustomerOrder } from '../types';
|
|
||||||
import {
|
import {
|
||||||
useFiestaDeliveries,
|
useFiestaAllOrders,
|
||||||
useFiestaDeliverySummary,
|
useFiestaDeliverySummary,
|
||||||
useFiestaRiders,
|
useFiestaRiders,
|
||||||
useFiestaOrderDetails,
|
useFiestaOrderDetails,
|
||||||
} from '../services/fiestaQueries';
|
} from '../services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||||
import { deliveryRowToOrder } from '../services/fiestaMappers';
|
import { shortTime } from '../services/fiestaMappers';
|
||||||
import AwaitingApi from './AwaitingApi';
|
import {
|
||||||
|
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, SearchPill, FilterBar, TH_STYLE,
|
||||||
|
ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT,
|
||||||
|
tint, soft, edge, ring,
|
||||||
|
} from './consoleUi';
|
||||||
|
|
||||||
interface OrdersDeliveriesViewProps {
|
interface OrdersDeliveriesViewProps {
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
isCoimbatoreView?: boolean;
|
isCoimbatoreView?: boolean;
|
||||||
locationid?: number;
|
locationid?: number;
|
||||||
|
tenantId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeliveryExecutive {
|
type StatusKey = 'all' | 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled';
|
||||||
id: string;
|
const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [
|
||||||
name: string;
|
{ key: 'all', label: 'All Orders' },
|
||||||
phone: string;
|
{ key: 'created', label: 'Created' },
|
||||||
status: 'Active Duty' | 'Idle' | 'Offline';
|
{ key: 'pending', label: 'Pending' },
|
||||||
completedToday: number;
|
{ key: 'processing', label: 'Processing' },
|
||||||
currentZone: string;
|
{ key: 'delivered', label: 'Delivered' },
|
||||||
avatar: string;
|
{ key: 'cancelled', label: 'Cancelled' },
|
||||||
}
|
|
||||||
|
|
||||||
const RIDER_AVATARS = [
|
|
||||||
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=150&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=150&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=150&q=80',
|
|
||||||
];
|
];
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
function riderRowToExecutive(row: Record<string, unknown>, idx: number): DeliveryExecutive {
|
export default function OrdersDeliveriesView({
|
||||||
return {
|
searchQuery = '',
|
||||||
id: `DE-${fstr(row.userid) || idx}`,
|
locationid,
|
||||||
name: fstr(row.fullname) || `${fstr(row.firstname)} ${fstr(row.lastname)}`.trim() || 'Rider',
|
tenantId = FIESTA_TENANT_ID,
|
||||||
phone: fstr(row.contactno) || '—',
|
}: OrdersDeliveriesViewProps) {
|
||||||
status: fstr(row.starttime) ? 'Active Duty' : 'Idle',
|
const todayStr = ymd(new Date());
|
||||||
completedToday: fnum(row.completed) || fnum(row.deliverycount),
|
|
||||||
currentZone: fstr(row.city) || fstr(row.vehiclename) || fstr(row.vehicleno) || 'Coimbatore',
|
|
||||||
avatar: RIDER_AVATARS[idx % RIDER_AVATARS.length],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreView = false, locationid }: OrdersDeliveriesViewProps) {
|
const [status, setStatus] = useState<StatusKey>('all');
|
||||||
// ── Live deliveries / fleet (Fiesta) ──────────────────────────────────────
|
const [pageno, setPageno] = useState(1);
|
||||||
// Order feed + dispatch controls run off the live deliveries board; the KPI
|
|
||||||
// strip uses the delivery summary; the fleet panel uses the active riders.
|
|
||||||
// A date-range filter lets the user view orders/deliveries day-wise.
|
|
||||||
const today = new Date();
|
|
||||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
||||||
const [fromdate, setFromdate] = useState<string>(ymd(monthStart));
|
|
||||||
const [todate, setTodate] = useState<string>(ymd(today));
|
|
||||||
|
|
||||||
// Quick-range presets (computed off the current day; no Date.now in render path).
|
|
||||||
const dayOffset = (n: number) => {
|
|
||||||
const d = new Date();
|
|
||||||
d.setDate(d.getDate() - n);
|
|
||||||
return ymd(d);
|
|
||||||
};
|
|
||||||
const presets: Array<{ key: string; label: string; from: string; to: string }> = [
|
|
||||||
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
|
||||||
{ key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) },
|
|
||||||
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
|
|
||||||
{ key: '30d', label: 'Last 30 Days', from: dayOffset(29), to: ymd(today) },
|
|
||||||
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: ymd(today) },
|
|
||||||
];
|
|
||||||
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
|
||||||
|
|
||||||
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
|
||||||
const summaryQ = useFiestaDeliverySummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
|
||||||
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
|
|
||||||
|
|
||||||
const [orders, setOrders] = useState<CustomerOrder[]>([]);
|
|
||||||
const [executives, setExecutives] = useState<DeliveryExecutive[]>([]);
|
|
||||||
const [selectedOrder, setSelectedOrder] = useState<CustomerOrder | null>(null);
|
|
||||||
const [filterStatus, setFilterStatus] = useState<string>('ALL');
|
|
||||||
const [localSearch, setLocalSearch] = useState('');
|
const [localSearch, setLocalSearch] = useState('');
|
||||||
|
const [detailOrder, setDetailOrder] = useState<Row | null>(null);
|
||||||
|
|
||||||
// Seed local state once live data arrives so existing dispatch/create handlers
|
// Search: Ctrl+K to focus, Escape to blur
|
||||||
// continue to mutate in-session.
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (deliveriesQ.data) {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
const mapped = deliveriesQ.data.map(deliveryRowToOrder);
|
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); searchRef.current?.focus(); }
|
||||||
setOrders(mapped);
|
else if (e.key === 'Escape' && document.activeElement === searchRef.current) searchRef.current?.blur();
|
||||||
// Keep the current selection only if it's still in the new range; otherwise
|
};
|
||||||
// fall back to the first order so the detail panel stays in sync.
|
document.addEventListener('keydown', onKey);
|
||||||
setSelectedOrder((prev) =>
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
(prev && mapped.some((o) => o.id === prev.id)) ? prev : mapped[0] ?? null,
|
}, []);
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [deliveriesQ.data]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// ── Queries ──────────────────────────────────────────────────────────────────
|
||||||
if (ridersQ.data) setExecutives(ridersQ.data.map(riderRowToExecutive));
|
const allOrdersQ = useFiestaAllOrders({ tenantid: tenantId, fromdate: todayStr, todate: todayStr, locationid });
|
||||||
}, [ridersQ.data]);
|
const summaryQ = useFiestaDeliverySummary({ tenantid: tenantId, fromdate: todayStr, todate: todayStr, locationid });
|
||||||
|
const ridersQ = useFiestaRiders({ tenantid: tenantId });
|
||||||
|
|
||||||
|
const allRows = allOrdersQ.data ?? [];
|
||||||
const summary = summaryQ.data;
|
const summary = summaryQ.data;
|
||||||
|
|
||||||
// Local filtered list of orders
|
// Per-status row counts (client-side from merged data)
|
||||||
const storeOrders = locationid ? orders.filter(o => o.locationid === locationid) : orders;
|
const statusCounts = useMemo(() => {
|
||||||
|
const acc: Record<string, number> = {};
|
||||||
|
for (const r of allRows) {
|
||||||
|
const s = fstr(r.orderstatus).toLowerCase();
|
||||||
|
acc[s] = (acc[s] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [allRows]);
|
||||||
|
|
||||||
const filteredOrdersList = storeOrders.filter(o => {
|
const countFor = (key: StatusKey) => key === 'all' ? allRows.length : (statusCounts[key] ?? 0);
|
||||||
|
|
||||||
|
const activeFleet = (ridersQ.data ?? []).filter((r) => fstr(r.starttime)).length;
|
||||||
|
|
||||||
|
// Filter by status + search (status='all' shows everything in the date range)
|
||||||
|
const rows = useMemo(() => {
|
||||||
const term = (localSearch || searchQuery).toLowerCase();
|
const term = (localSearch || searchQuery).toLowerCase();
|
||||||
const matchesSearch = o.id.toLowerCase().includes(term) ||
|
return allRows.filter((r) => {
|
||||||
o.customerName.toLowerCase().includes(term) ||
|
if (locationid && fnum(r.locationid) !== locationid) return false;
|
||||||
o.address.toLowerCase().includes(term);
|
if (status !== 'all' && fstr(r.orderstatus).toLowerCase() !== status) return false;
|
||||||
const matchesFilter = filterStatus === 'ALL' || o.status === filterStatus;
|
if (!term) return true;
|
||||||
return matchesSearch && matchesFilter;
|
return [
|
||||||
|
r.orderid, r.orderheaderid, r.orderstatus, r.tenantname,
|
||||||
|
r.pickupcustomer, r.pickupcontactno, r.pickupaddress, r.pickupsuburb,
|
||||||
|
r.deliverycustomer, r.deliverycontactno, r.deliveryaddress, r.deliverysuburb,
|
||||||
|
r.applocation, r.locationname, r.ridername,
|
||||||
|
].some((v) => fstr(v).toLowerCase().includes(term));
|
||||||
});
|
});
|
||||||
|
}, [allRows, status, localSearch, searchQuery, locationid]);
|
||||||
|
|
||||||
// Calculate dynamic stats for metrics cards based on filtered storeOrders
|
// Pagination
|
||||||
const totalDeliveriesCount = storeOrders.length;
|
const pageRows = useMemo(
|
||||||
const pendingFulfillmentCount = storeOrders.filter(o => o.status === 'PROCESSING' || o.status === 'CONFIRMED').length;
|
() => rows.slice((pageno - 1) * PAGE_SIZE, pageno * PAGE_SIZE),
|
||||||
const activeDispatchCount = storeOrders.filter(o => o.status === 'OUT_FOR_DELIVERY').length;
|
[rows, pageno],
|
||||||
const completedDeliveriesCount = storeOrders.filter(o => o.status === 'DELIVERED').length;
|
);
|
||||||
|
const hasNext = rows.length > pageno * PAGE_SIZE;
|
||||||
|
|
||||||
// Live line-item details for the currently selected order. The deliveries board
|
// Totals
|
||||||
// only carries an itemCount; the actual basket lines come from this endpoint.
|
const totals = useMemo(() => {
|
||||||
const orderDetailsQ = useFiestaOrderDetails(selectedOrder?.id ?? null);
|
let cod = 0, amount = 0;
|
||||||
const orderItems = (orderDetailsQ.data ?? []).map((row) => {
|
for (const r of rows) {
|
||||||
const quantity = fnum(row.quantity) || fnum(row.qty);
|
cod += fnum(r.collectionamt);
|
||||||
const price = fnum(row.price) || fnum(row.unitprice);
|
amount += fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
|
||||||
const lineTotal = fnum(row.amount) || price * quantity;
|
}
|
||||||
|
return { cod, amount };
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const total = allRows.length;
|
||||||
|
const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0);
|
||||||
|
|
||||||
|
const kpis = [
|
||||||
|
{ label: 'Created Orders', value: countFor('created').toLocaleString('en-IN'), color: '#475569', icon: <ShoppingBag size={20} />, badge: `${pct(countFor('created'))}% of total` },
|
||||||
|
{ label: 'Pending Orders', value: countFor('pending').toLocaleString('en-IN'), color: '#9a6700', icon: <Clock size={20} />, badge: `${pct(countFor('pending'))}% of total` },
|
||||||
|
{ label: 'Delivered Orders', value: countFor('delivered').toLocaleString('en-IN'), color: '#15803d', icon: <CheckCircle2 size={20} />, badge: `${pct(countFor('delivered'))}% of total` },
|
||||||
|
{ label: 'Cancelled Orders', value: countFor('cancelled').toLocaleString('en-IN'), color: '#b42318', icon: <XCircle size={20} />, badge: `${pct(countFor('cancelled'))}% of total` },
|
||||||
|
];
|
||||||
|
|
||||||
|
// CSV export
|
||||||
|
const exportCsv = () => {
|
||||||
|
const headers = ['#', 'Order ID', 'Status', 'Branch', 'Order Date', 'Pickup', 'Pickup Contact', 'Pickup Address', 'Drop', 'Drop Contact', 'Drop Address', 'Qty', 'COD', 'Amount'];
|
||||||
|
const esc = (v: unknown) => `"${fstr(v).replace(/"/g, '""')}"`;
|
||||||
|
const lines = rows.map((r, i) => [
|
||||||
|
i + 1,
|
||||||
|
fstr(r.orderid) || fstr(r.orderheaderid),
|
||||||
|
fstr(r.orderstatus),
|
||||||
|
fstr(r.applocation) || fstr(r.locationname),
|
||||||
|
shortTime(r.orderdate || r.deliverydate),
|
||||||
|
fstr(r.pickupcustomer) || fstr(r.tenantname),
|
||||||
|
fstr(r.pickupcontactno),
|
||||||
|
fstr(r.pickupaddress) || fstr(r.pickupsuburb),
|
||||||
|
fstr(r.deliverycustomer),
|
||||||
|
fstr(r.deliverycontactno),
|
||||||
|
fstr(r.deliveryaddress) || fstr(r.deliverysuburb),
|
||||||
|
fnum(r.quantity),
|
||||||
|
fnum(r.collectionamt),
|
||||||
|
fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt),
|
||||||
|
].map(esc).join(','));
|
||||||
|
const blob = new Blob([[headers.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url;
|
||||||
|
a.download = `Orders_${status}_${todayStr}.csv`; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-in fade-in duration-300">
|
||||||
|
<GradientHeader
|
||||||
|
title="Orders & Deliveries"
|
||||||
|
subtitle="Live order board across the full lifecycle — created, pending, processing, delivered, and cancelled."
|
||||||
|
status={
|
||||||
|
allOrdersQ.isLoading
|
||||||
|
? <LiveStatus state="loading" label="Loading live orders…" />
|
||||||
|
: allOrdersQ.isError
|
||||||
|
? <LiveStatus state="error" label="Live data unavailable" />
|
||||||
|
: <LiveStatus state="live" label={`Live · ${total.toLocaleString('en-IN')} orders in range · ${activeFleet} riders on duty`} />
|
||||||
|
}
|
||||||
|
right={
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full font-extrabold"
|
||||||
|
style={{ padding: '6px 12px', fontSize: 12, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}>
|
||||||
|
<MapPin size={13} /> {locationid ? `Location ${locationid}` : 'All Locations'}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4"><KpiStrip items={kpis} loading={allOrdersQ.isLoading} /></div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Status tabs + search + CSV */}
|
||||||
|
<FilterBar className="mb-4">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0">
|
||||||
|
{STATUS_TABS.map((t) => (
|
||||||
|
<React.Fragment key={t.key}>
|
||||||
|
<Pill active={status === t.key} color={BRAND} onClick={() => { setStatus(t.key); setPageno(1); }}
|
||||||
|
count={allOrdersQ.isLoading ? '·' : countFor(t.key).toLocaleString('en-IN')}>
|
||||||
|
{t.label}
|
||||||
|
</Pill>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 w-full lg:w-auto lg:shrink-0">
|
||||||
|
<div className="w-full lg:w-64">
|
||||||
|
<SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders (Ctrl+K)…" inputRef={searchRef} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={exportCsv}
|
||||||
|
disabled={rows.length === 0}
|
||||||
|
title="Export current view to CSV"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap shrink-0"
|
||||||
|
style={{ padding: '8px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
|
||||||
|
>
|
||||||
|
<Download size={13} /> CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full" style={{ minWidth: 900 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'Amount', 'Status', ''].map((h, i) => (
|
||||||
|
<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{allOrdersQ.isLoading ? (
|
||||||
|
<tr><td colSpan={10} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
|
||||||
|
<span className="inline-flex items-center gap-2 text-xs font-semibold">
|
||||||
|
<Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading orders…
|
||||||
|
</span>
|
||||||
|
</td></tr>
|
||||||
|
) : pageRows.length === 0 ? (
|
||||||
|
<tr><td colSpan={10} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>
|
||||||
|
No {status} orders found for this date range or search.
|
||||||
|
</td></tr>
|
||||||
|
) : (
|
||||||
|
pageRows.map((r, i) => {
|
||||||
|
const st = fstr(r.orderstatus).toLowerCase();
|
||||||
|
const cod = fnum(r.collectionamt);
|
||||||
|
const amount = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={fstr(r.orderid) || fstr(r.orderheaderid) || i}
|
||||||
|
className="transition-colors align-top"
|
||||||
|
style={{ borderBottom: `1px solid ${DIVIDER}` }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
|
||||||
|
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
|
||||||
|
<MapPin size={11} /> {fstr(r.applocation) || '—'}
|
||||||
|
</span>
|
||||||
|
{fstr(r.locationname) && <p className="text-[10px] truncate max-w-[120px]" style={{ color: TEXT_2 }}>{fstr(r.locationname)}</p>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
|
||||||
|
<p className="text-[10px] truncate max-w-[140px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
|
||||||
|
<p className="text-[10px] truncate max-w-[140px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td>
|
||||||
|
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: cod > 0 ? TEXT : TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'}</td>
|
||||||
|
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: amount > 0 ? TEXT : TEXT_3 }}>{amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—'}</td>
|
||||||
|
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
|
||||||
|
<td className="px-3 py-2.5 text-right">
|
||||||
|
<button onClick={() => setDetailOrder(r)}
|
||||||
|
className="rounded-full font-extrabold cursor-pointer transition-colors"
|
||||||
|
style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Totals footer */}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2 px-4 py-2.5 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||||
|
<span className="text-[10px] font-extrabold uppercase tracking-wider mr-auto" style={{ color: TEXT_2 }}>
|
||||||
|
Totals · {rows.length} order{rows.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
{totals.cod > 0 && <TotalChip label="COD" value={`₹${totals.cod.toLocaleString('en-IN')}`} color={TEXT_2} />}
|
||||||
|
<TotalChip label="Amount" value={`₹${totals.amount.toLocaleString('en-IN')}`} color={BRAND} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}>
|
||||||
|
Page {pageno} · {pageRows.length} of {rows.length} shown
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PagerBtn disabled={pageno === 1} onClick={() => setPageno((p) => Math.max(1, p - 1))}>
|
||||||
|
<ChevronLeft size={13} /> Prev
|
||||||
|
</PagerBtn>
|
||||||
|
<PagerBtn disabled={!hasNext} onClick={() => setPageno((p) => p + 1)}>
|
||||||
|
Next <ChevronRight size={13} />
|
||||||
|
</PagerBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TotalChip({ label, value, color }: { label: string; value: string; color: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full font-bold"
|
||||||
|
style={{ padding: '4px 11px', fontSize: 11.5, background: soft(color), color, border: `1px solid ${edge(color)}` }}>
|
||||||
|
<span className="uppercase tracking-wider text-[9px] font-extrabold opacity-80">{label}</span>
|
||||||
|
<span className="font-mono">{value}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} disabled={disabled}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Order details modal ────────────────────────────────────────────────────────
|
||||||
|
function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void }) {
|
||||||
|
const orderheaderid = order.orderheaderid ?? order.orderid;
|
||||||
|
const detailsQ = useFiestaOrderDetails(orderheaderid as number | string);
|
||||||
|
const lines = (detailsQ.data ?? []).map((row) => {
|
||||||
|
const quantity = fnum(row.quantity) || fnum(row.qty) || fnum(row.orderqty);
|
||||||
|
const price = fnum(row.price) || fnum(row.unitprice) || fnum(row.retailprice);
|
||||||
return {
|
return {
|
||||||
name: fstr(row.productname) || fstr(row.itemname) || 'Item',
|
name: fstr(row.productname) || fstr(row.itemname) || 'Item',
|
||||||
quantity,
|
quantity,
|
||||||
price,
|
price,
|
||||||
lineTotal,
|
lineTotal: fnum(row.amount) || fnum(row.productsumprice) || price * quantity,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
const st = fstr(order.orderstatus).toLowerCase();
|
||||||
<div className="space-y-lg animate-in fade-in duration-500">
|
const total = fnum(order.ordervalue) || fnum(order.orderamount) || fnum(order.deliveryamt);
|
||||||
|
const rider = fstr(order.ridername) || fstr(order.username);
|
||||||
|
|
||||||
{/* View Header with Statistics Overview */}
|
const STEPS = [
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-md border-b border-[#e2e8f0] pb-xl">
|
{ label: 'Order Placed', field: 'orderdate' },
|
||||||
<div>
|
{ label: 'Confirmed', field: 'starttime' },
|
||||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
|
{ label: 'Packed & Ready', field: 'packtime' },
|
||||||
Orders & Delivery Operations
|
{ label: 'Out for Delivery',field: 'pickuptime' },
|
||||||
</h1>
|
{ label: 'Delivered', field: 'deliverytime' },
|
||||||
<p className="text-zinc-500 font-sans text-xs mt-1">
|
];
|
||||||
Real-time tracking of app orders, dispatch queues, and active delivery partners across Coimbatore regional sub-hubs.
|
|
||||||
</p>
|
|
||||||
<div className="mt-1.5">
|
|
||||||
{deliveriesQ.isLoading ? (
|
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading live deliveries…
|
|
||||||
</span>
|
|
||||||
) : deliveriesQ.isError ? (
|
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Live data unavailable
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {orders.length} deliveries · {executives.length} riders
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Top Level Delivery Performance Indicators */}
|
return createPortal(
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-gutter font-sans">
|
|
||||||
|
|
||||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
|
|
||||||
<div className="p-2 bg-purple-50 text-[#581c87] rounded-lg">
|
|
||||||
<ShoppingBag size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Deliveries in Range</p>
|
|
||||||
<p className="font-sans font-bold text-lg text-zinc-800">{totalDeliveriesCount.toLocaleString('en-IN')} total</p>
|
|
||||||
<p className="text-[10px] text-emerald-600 font-semibold mt-0.5">{fromdate === todate ? fromdate : `${fromdate} → ${todate}`}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
|
|
||||||
<div className="p-2 bg-amber-50 text-amber-600 rounded-lg">
|
|
||||||
<Clock size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Pending Fulfilment</p>
|
|
||||||
<p className="font-sans font-bold text-lg text-zinc-800">
|
|
||||||
{pendingFulfillmentCount + activeDispatchCount} active
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] text-amber-600 font-semibold mt-0.5">Awaiting dispatch / in transit</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
|
|
||||||
<div className="p-2 bg-emerald-50 text-emerald-600 rounded-lg">
|
|
||||||
<Truck size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Successful Deliveries</p>
|
|
||||||
<p className="font-sans font-bold text-lg text-zinc-800">
|
|
||||||
{completedDeliveriesCount} done
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] text-[#581c87] font-semibold mt-0.5">{locationid ? 'At this location' : 'Across all locations'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
|
|
||||||
<div className="p-2 bg-purple-50 text-purple-600 rounded-lg">
|
|
||||||
<UserCheck size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Active Delivery Fleet</p>
|
|
||||||
<p className="font-sans font-bold text-lg text-zinc-800">
|
|
||||||
{executives.filter(e => e.status !== 'Offline').length} partners
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] text-purple-600 font-semibold mt-0.5">{executives.length} riders registered</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Day-wise date filter — drives the live deliveries + summary queries */}
|
|
||||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm flex flex-col lg:flex-row lg:items-center justify-between gap-md">
|
|
||||||
<div className="flex items-center gap-sm flex-wrap">
|
|
||||||
<span className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 uppercase tracking-widest pr-1">
|
|
||||||
<Calendar size={13} className="text-[#581c87]" /> View
|
|
||||||
</span>
|
|
||||||
{presets.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p.key}
|
|
||||||
onClick={() => { setFromdate(p.from); setTodate(p.to); }}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border cursor-pointer ${
|
|
||||||
activePreset === p.key
|
|
||||||
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
|
|
||||||
: 'bg-white text-zinc-600 border-[#e2e8f0] hover:bg-zinc-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{p.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-sm text-xs">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<label className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider">From</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={fromdate}
|
|
||||||
max={todate}
|
|
||||||
onChange={(e) => setFromdate(e.target.value)}
|
|
||||||
className="border border-[#e2e8f0] rounded-lg px-2 py-1.5 text-zinc-700 font-medium outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-zinc-300">→</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<label className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider">To</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={todate}
|
|
||||||
min={fromdate}
|
|
||||||
max={ymd(today)}
|
|
||||||
onChange={(e) => setTodate(e.target.value)}
|
|
||||||
className="border border-[#e2e8f0] rounded-lg px-2 py-1.5 text-zinc-700 font-medium outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main interactive segment splits */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter text-xs font-sans">
|
|
||||||
|
|
||||||
{/* Left List of Customer App Orders */}
|
|
||||||
<div className="lg:col-span-2 flex">
|
|
||||||
<div className="bg-white border border-[#e2e8f0] rounded-xl shadow-sm overflow-hidden flex flex-col h-full w-full min-h-[32rem]">
|
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
|
||||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex flex-col gap-md shrink-0">
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-sm">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
|
|
||||||
Customer Orders Feed ({filteredOrdersList.length})
|
|
||||||
</h4>
|
|
||||||
<p className="text-[10px] text-zinc-400 font-medium mt-0.5">Interactive list of customer purchases made via client app</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-sm w-full">
|
|
||||||
{/* Local Search Input */}
|
|
||||||
<div className="relative w-full sm:max-w-xs">
|
|
||||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search orders by customer, street, ID..."
|
|
||||||
value={localSearch}
|
|
||||||
onChange={(e) => setLocalSearch(e.target.value)}
|
|
||||||
className="w-full pl-8 pr-4 py-1.5 border border-[#e2e8f0] rounded-lg text-[11px] outline-none bg-white focus:ring-1 focus:ring-[#581c87] transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter Status buttons */}
|
|
||||||
<div className="flex gap-1 overflow-x-auto w-full sm:w-auto">
|
|
||||||
{['ALL', 'PROCESSING', 'CONFIRMED', 'OUT_FOR_DELIVERY', 'DELIVERED'].map((st) => (
|
|
||||||
<button
|
|
||||||
key={st}
|
|
||||||
onClick={() => setFilterStatus(st)}
|
|
||||||
className={`px-2 py-1.5 rounded text-[9px] font-bold uppercase transition-all border outline-none cursor-pointer whitespace-nowrap ${
|
|
||||||
filterStatus === st
|
|
||||||
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
|
|
||||||
: 'bg-white text-zinc-500 border-[#e2e8f0] hover:bg-zinc-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{st.replace(/_/g, ' ')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Order item rows — flex-fills the column so the feed matches the Order Details card height */}
|
|
||||||
<div className="divide-y divide-[#f1f5f9] flex-1 min-h-0 overflow-y-auto">
|
|
||||||
{filteredOrdersList.length === 0 ? (
|
|
||||||
<div className="p-xl text-center text-zinc-400 font-medium">
|
|
||||||
No orders matching status filter found. Try another query or adjust the date range.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredOrdersList.map(order => (
|
|
||||||
<div
|
<div
|
||||||
key={order.id}
|
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
|
||||||
onClick={() => setSelectedOrder(order)}
|
style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }}
|
||||||
className={`p-md flex items-center justify-between hover:bg-zinc-50 border-l-4 transition-all cursor-pointer ${
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
selectedOrder?.id === order.id ? 'bg-[#faf5ff]/50 border-[#581c87]' : 'border-transparent'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div
|
||||||
<div className="flex items-center gap-sm">
|
className="bg-white max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200"
|
||||||
<span className="font-bold text-zinc-700">{order.customerName}</span>
|
style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}
|
||||||
<span className="text-[10px] text-zinc-400">• {order.time}</span>
|
>
|
||||||
</div>
|
{/* Brand accent bar */}
|
||||||
<p className="text-zinc-500 truncate max-w-[24rem]">{order.address}</p>
|
<div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)` }} />
|
||||||
<div className="flex gap-sm py-1 items-center">
|
|
||||||
<span className="bg-[#f1f5f9] px-1.5 py-0.5 rounded text-[9px] font-bold text-zinc-500 uppercase">{order.hub}</span>
|
{/* Modal header */}
|
||||||
<span className="text-[9px] text-[#581c87] font-bold">{order.itemCount ?? order.items.length} Items</span>
|
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||||
</div>
|
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}>
|
||||||
|
<Package size={16} style={{ color: BRAND }} />
|
||||||
|
Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}
|
||||||
|
</h4>
|
||||||
|
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-right space-y-1">
|
{/* Body */}
|
||||||
<p className="font-bold font-mono text-sm text-[#0f172a]">₹{order.amount.toLocaleString()}</p>
|
<div className="p-4 space-y-4 overflow-y-auto flex-1">
|
||||||
<span className={`px-2 py-0.5 rounded text-[9px] font-bold tracking-wider inline-block uppercase ${
|
{/* Status + rider */}
|
||||||
order.status === 'DELIVERED'
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
? 'bg-emerald-50 text-emerald-600 border border-emerald-100'
|
<StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} />
|
||||||
: order.status === 'OUT_FOR_DELIVERY'
|
<div className="flex items-center gap-3">
|
||||||
? 'bg-purple-50 text-purple-700 border border-purple-100'
|
{rider && (
|
||||||
: order.status === 'CONFIRMED'
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_2 }}>
|
||||||
? 'bg-amber-50 text-amber-600 border border-amber-100 animate-pulse'
|
<UserCheck size={12} style={{ color: BRAND }} /> {rider}
|
||||||
: 'bg-zinc-100 text-zinc-650 border border-zinc-200'
|
|
||||||
}`}>
|
|
||||||
{order.status.replace(/_/g, ' ')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
<span className="text-[11px] font-medium" style={{ color: TEXT_2 }}>{shortTime(order.orderdate || order.deliverydate)}</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right column — Order Details, shown parallel to the orders feed */}
|
{/* Customer card */}
|
||||||
<div className="lg:col-span-1 space-y-md">
|
<div className="p-3 rounded-xl space-y-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
||||||
{selectedOrder ? (
|
<div className="flex items-center gap-2 font-bold" style={{ color: TEXT }}>
|
||||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm space-y-md animate-in zoom-in-95 duration-150">
|
{fstr(order.deliverycustomer) || 'Customer'}
|
||||||
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
|
</div>
|
||||||
Order Details: {selectedOrder.id}
|
{fstr(order.deliverycontactno) && (
|
||||||
|
<div className="flex items-center gap-2 font-mono text-xs" style={{ color: TEXT_2 }}>
|
||||||
|
<Phone size={12} /> {fstr(order.deliverycontactno)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-start gap-2 text-xs" style={{ color: TEXT_2 }}>
|
||||||
|
<MapPin size={12} className="mt-0.5 shrink-0" />
|
||||||
|
<span className="leading-relaxed">{fstr(order.deliveryaddress) || fstr(order.deliverysuburb) || 'Address unavailable'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delivery timeline */}
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2" style={{ color: TEXT_2 }}>
|
||||||
|
Delivery Timeline
|
||||||
</span>
|
</span>
|
||||||
|
<div className="space-y-2.5 pl-1">
|
||||||
{/* Customer summary */}
|
{STEPS.map((s) => {
|
||||||
<div className="p-sm bg-[#f8fafc] rounded-lg border border-[#e2e8f0]/50 space-y-xs">
|
const ts = fstr(order[s.field]);
|
||||||
<div className="flex justify-between font-semibold">
|
const done = Boolean(ts);
|
||||||
<span>Customer Name</span>
|
return (
|
||||||
<span className="text-zinc-700">{selectedOrder.customerName}</span>
|
<div key={s.field} className="flex items-center gap-2.5">
|
||||||
</div>
|
<CheckCircle2 size={13} style={{ color: done ? '#10b981' : '#cbd5e1' }} />
|
||||||
<div className="flex justify-between font-semibold">
|
<span className="font-semibold text-xs" style={{ color: done ? TEXT : TEXT_3 }}>{s.label}</span>
|
||||||
<span>Contact info</span>
|
<span className="ml-auto text-[10px] font-mono" style={{ color: TEXT_3 }}>{done ? shortTime(ts) : '—'}</span>
|
||||||
<span className="text-zinc-600 font-mono">{selectedOrder.phone}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] text-zinc-400 font-bold uppercase block mt-1">Delivery Address</span>
|
|
||||||
<p className="text-zinc-700 mt-0.5 leading-relaxed font-medium">{selectedOrder.address}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category items description list */}
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] text-zinc-400 font-bold uppercase tracking-wide block mb-sm">Ordered Grocery basket Items:</span>
|
|
||||||
<div className="divide-y divide-[#f1f5f9] bg-zinc-50/50 p-2.5 rounded-lg border border-[#e2e8f0]/40">
|
|
||||||
{orderDetailsQ.isLoading && (
|
|
||||||
<div className="py-2 flex items-center gap-1.5 text-[10px] text-zinc-400 font-medium">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading order line items…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!orderDetailsQ.isLoading && orderItems.length === 0 && (
|
|
||||||
<div className="py-2 flex justify-between items-center text-xs text-zinc-500">
|
|
||||||
<span className="font-medium">{selectedOrder.itemCount ?? 0} line item(s)</span>
|
|
||||||
<span className="text-[10px] text-zinc-400">Detail lines not loaded on board view</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{orderItems.map((item, idx) => (
|
|
||||||
<div key={idx} className="py-2 flex justify-between items-center text-xs">
|
|
||||||
<div>
|
|
||||||
<p className="font-bold text-[#0f172a]">{item.name}</p>
|
|
||||||
<p className="text-[10px] text-zinc-400">Qty: {item.quantity} x ₹{item.price}</p>
|
|
||||||
</div>
|
|
||||||
<span className="font-bold font-mono text-zinc-700">₹{item.lineTotal}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="pt-2 flex justify-between items-center font-bold text-sm text-[#581c87] border-t border-dashed border-[#e2e8f0]">
|
|
||||||
<span>Grand Total Invoice</span>
|
|
||||||
<span className="font-mono">₹{selectedOrder.amount.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Live GPS route tracker — no rider-telemetry/GPS API yet */}
|
|
||||||
{selectedOrder.status === 'OUT_FOR_DELIVERY' && (
|
|
||||||
<div className="space-y-xs pt-xs">
|
|
||||||
<span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest block">
|
|
||||||
LIVE GPS ROUTE TRACKER
|
|
||||||
</span>
|
|
||||||
<AwaitingApi label="Live rider GPS & ETA" api="[R9]" compact />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delivery tracking visual roadmap layout */}
|
|
||||||
<div className="bg-zinc-50 border border-[#e2e8f0]/60 rounded-xl p-md">
|
|
||||||
<span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs mb-sm border-b border-[#f1f5f9]">
|
|
||||||
Live Dispatch Timeline Tracker
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="space-y-xs pt-1 relative text-[11px]">
|
|
||||||
<div className="flex gap-md items-start relative group">
|
|
||||||
<span className="text-emerald-500 mt-0.5"><CheckCircle2 size={12} /></span>
|
|
||||||
<div>
|
|
||||||
<h5 className="font-semibold text-zinc-800">Order Received ({selectedOrder.time})</h5>
|
|
||||||
<p className="text-[10px] text-zinc-400">Placed via customer app cart checkout successfully.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-md items-start pt-3">
|
|
||||||
<span className={['CONFIRMED', 'OUT_FOR_DELIVERY', 'DELIVERED'].includes(selectedOrder.status) ? 'text-emerald-500 mt-0.5' : 'text-zinc-300 mt-0.5'}><CheckCircle2 size={12} /></span>
|
|
||||||
<div>
|
|
||||||
<h5 className="font-semibold text-zinc-800">Assortment Packaged & Bagged</h5>
|
|
||||||
<p className="text-[10px] text-zinc-400">Verified fresh produce items in-stock levels.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-md items-start pt-3">
|
|
||||||
<span className={['OUT_FOR_DELIVERY', 'DELIVERED'].includes(selectedOrder.status) ? 'text-emerald-500 mt-0.5' : 'text-zinc-300 mt-0.5'}><CheckCircle2 size={12} /></span>
|
|
||||||
<div>
|
|
||||||
<h5 className="font-semibold text-zinc-800">Out for Delivery</h5>
|
|
||||||
<p className="text-[10px] text-zinc-400">Dispatched with executive partner on bike route.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-md items-start pt-3">
|
|
||||||
<span className={selectedOrder.status === 'DELIVERED' ? 'text-emerald-500 mt-0.5' : 'text-zinc-300 mt-0.5'}><CheckCircle2 size={12} /></span>
|
|
||||||
<div>
|
|
||||||
<h5 className="font-semibold text-zinc-800">Handover Verified</h5>
|
|
||||||
<p className="text-[10px] text-zinc-400">Delivered directly to door step location.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="p-xl bg-white border border-[#e2e8f0] rounded-xl text-center text-zinc-400 font-medium">
|
|
||||||
Select any customer order from the feed to view its details.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line items */}
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2 flex items-center gap-1.5" style={{ color: TEXT_2 }}>
|
||||||
|
<Package size={12} /> Items
|
||||||
|
</span>
|
||||||
|
<div className="rounded-xl p-3" style={{ background: 'rgba(248,250,252,0.6)', border: `1px solid ${BORDER}` }}>
|
||||||
|
{detailsQ.isLoading && (
|
||||||
|
<div className="py-2 flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_3 }}>
|
||||||
|
<Loader2 size={12} className="animate-spin" /> Loading line items…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!detailsQ.isLoading && lines.length === 0 && (
|
||||||
|
<div className="py-2 text-[11px] font-medium" style={{ color: TEXT_3 }}>
|
||||||
|
No line items returned for this order.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lines.map((item, idx) => (
|
||||||
|
<div key={idx} className="py-2 flex justify-between items-center"
|
||||||
|
style={{ borderTop: idx ? `1px solid ${DIVIDER}` : undefined }}>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-xs" style={{ color: TEXT }}>{item.name}</p>
|
||||||
|
<p className="text-[10px]" style={{ color: TEXT_2 }}>Qty: {item.quantity} × ₹{item.price}</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-extrabold font-mono text-xs" style={{ color: TEXT }}>₹{item.lineTotal.toLocaleString('en-IN')}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{total > 0 && (
|
||||||
|
<div className="pt-2 mt-1 flex justify-between items-center font-extrabold text-sm"
|
||||||
|
style={{ color: BRAND, borderTop: `1px dashed ${BORDER}` }}>
|
||||||
|
<span>Order Total</span>
|
||||||
|
<span className="font-mono">₹{total.toLocaleString('en-IN')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||||
|
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white"
|
||||||
|
style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,19 +11,22 @@
|
|||||||
* to the live Fiesta order endpoints (status-scoped, date-ranged, paginated).
|
* to the live Fiesta order endpoints (status-scoped, date-ranged, paginated).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||||
import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2 } from 'lucide-react';
|
import { createPortal } from 'react-dom';
|
||||||
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails } from '../services/fiestaQueries';
|
import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2, Download, UserCheck, ClipboardList, ArrowLeft } from 'lucide-react';
|
||||||
|
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider } from '../services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||||
import { shortTime } from '../services/fiestaMappers';
|
import { shortTime } from '../services/fiestaMappers';
|
||||||
import {
|
import {
|
||||||
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
|
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
|
||||||
ORDER_STATUS, statusColor, BRAND, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge,
|
ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge, ring,
|
||||||
} from './consoleUi';
|
} from './consoleUi';
|
||||||
|
|
||||||
interface OrdersViewProps {
|
interface OrdersViewProps {
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
locationid?: number;
|
locationid?: number;
|
||||||
|
/** Merchant tenant to scope to; defaults to the shared constant. */
|
||||||
|
tenantId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusKey = 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled';
|
type StatusKey = 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled';
|
||||||
@@ -36,57 +39,167 @@ const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [
|
|||||||
];
|
];
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
export default function OrdersView({ searchQuery = '', locationid }: OrdersViewProps) {
|
export default function OrdersView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID }: OrdersViewProps) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
const [fromdate, setFromdate] = useState<string>(ymd(today));
|
const [fromdate, setFromdate] = useState<string>(ymd(today));
|
||||||
const [todate, setTodate] = useState<string>(ymd(today));
|
const [todate, setTodate] = useState<string>(ymd(today));
|
||||||
|
|
||||||
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); };
|
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); };
|
||||||
|
const dayAhead = (n: number) => { const d = new Date(); d.setDate(d.getDate() + n); return ymd(d); };
|
||||||
|
// NOTE: the backend lists orders by DELIVERY date (deliverytime), not creation
|
||||||
|
// date — so an order created today for a future slot only appears once the range
|
||||||
|
// covers its delivery date. "Next 7 Days" surfaces upcoming-delivery orders.
|
||||||
|
// "All time" can't pass empty dates (the query is gated on from/to), so it uses
|
||||||
|
// a wide window — from the platform's earliest plausible data to a year ahead.
|
||||||
const presets = [
|
const presets = [
|
||||||
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
||||||
{ key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) },
|
|
||||||
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
|
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
|
||||||
{ key: '30d', label: 'Last 30 Days', from: dayOffset(29), to: ymd(today) },
|
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: dayAhead(7) },
|
||||||
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: ymd(today) },
|
|
||||||
];
|
];
|
||||||
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
||||||
|
|
||||||
const [status, setStatus] = useState<StatusKey>('created');
|
const [status, setStatus] = useState<StatusKey>('created');
|
||||||
const [pageno, setPageno] = useState(1);
|
const [pageno, setPageno] = useState(1);
|
||||||
const [localSearch, setLocalSearch] = useState('');
|
const [localSearch, setLocalSearch] = useState('');
|
||||||
|
const [branch, setBranch] = useState(0); // applocationid filter (0 = all branches)
|
||||||
const [detailOrder, setDetailOrder] = useState<Row | null>(null);
|
const [detailOrder, setDetailOrder] = useState<Row | null>(null);
|
||||||
|
|
||||||
const summaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, fromdate, todate);
|
// ── Multi-select rider assignment (parity with the ops console) ─────────────
|
||||||
const ordersQ = useFiestaOrders({ tenantid: FIESTA_TENANT_ID, status, fromdate, todate, pageno, pagesize: PAGE_SIZE });
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [assignRiderId, setAssignRiderId] = useState(0);
|
||||||
|
const [assignMsg, setAssignMsg] = useState('');
|
||||||
|
const [showSelected, setShowSelected] = useState(false); // full-page review of selection
|
||||||
|
const assignMut = useFiestaAssignRider();
|
||||||
|
|
||||||
|
// Ctrl/Cmd+K focuses search; Escape blurs it (parity with the ops console).
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
searchRef.current?.focus();
|
||||||
|
} else if (e.key === 'Escape' && document.activeElement === searchRef.current) {
|
||||||
|
searchRef.current?.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset the selection whenever the visible result set changes, so an assign
|
||||||
|
// can never act on rows the operator can no longer see.
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(new Set());
|
||||||
|
setAssignMsg('');
|
||||||
|
setShowSelected(false);
|
||||||
|
}, [fromdate, todate, status, branch, pageno, locationid]);
|
||||||
|
|
||||||
|
// Scope to the user's store when a locationid is supplied (server-side per the
|
||||||
|
// backend's getordersummary/getorders locationid param); tenant-wide otherwise.
|
||||||
|
const summaryQ = useFiestaOrderSummary(tenantId, fromdate, todate, locationid);
|
||||||
|
const ordersQ = useFiestaOrders({ tenantid: tenantId, status, fromdate, todate, locationid, pageno, pagesize: PAGE_SIZE });
|
||||||
const summary = summaryQ.data;
|
const summary = summaryQ.data;
|
||||||
const rawRows = ordersQ.data ?? [];
|
const rawRows = ordersQ.data ?? [];
|
||||||
|
|
||||||
|
// Riders must share the orders' tenant + partner to be assignable (the backend
|
||||||
|
// rejects cross-tenant/partner riders), so derive the partner/app-location from
|
||||||
|
// the live order rows and scope the rider list to them. An out-of-tenant rider
|
||||||
|
// simply won't appear — the intended guard.
|
||||||
|
const orderPartnerId = useMemo(() => fnum(rawRows.find((r) => fnum(r.partnerid))?.partnerid), [rawRows]);
|
||||||
|
const orderApplocationId = useMemo(() => fnum(rawRows.find((r) => fnum(r.applocationid))?.applocationid), [rawRows]);
|
||||||
|
const ridersQ = useFiestaRiders({
|
||||||
|
tenantid: tenantId,
|
||||||
|
applocationid: orderApplocationId || undefined,
|
||||||
|
partnerid: orderPartnerId || undefined,
|
||||||
|
});
|
||||||
|
const riderOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(ridersQ.data ?? [])
|
||||||
|
.map((r) => ({
|
||||||
|
id: fnum(r.userid),
|
||||||
|
label: `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() + (fstr(r.contactno) ? ` · ${fstr(r.contactno)}` : ''),
|
||||||
|
}))
|
||||||
|
.filter((o) => o.id > 0 && o.label),
|
||||||
|
[ridersQ.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Branches (app-locations) present in the data — drives the branch filter so the
|
||||||
|
// operator can see which branch an order was placed at. Each order row carries
|
||||||
|
// applocationid + applocation (the app-location name).
|
||||||
|
const branches = useMemo(() => {
|
||||||
|
const m = new Map<number, string>();
|
||||||
|
for (const r of rawRows) {
|
||||||
|
const id = fnum(r.applocationid);
|
||||||
|
if (id && !m.has(id)) m.set(id, fstr(r.applocation) || fstr(r.locationname) || `Branch ${id}`);
|
||||||
|
}
|
||||||
|
return [...m.entries()].map(([id, name]) => ({ id, name }));
|
||||||
|
}, [rawRows]);
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const term = (localSearch || searchQuery).toLowerCase();
|
const term = (localSearch || searchQuery).toLowerCase();
|
||||||
return rawRows.filter((r) => {
|
return rawRows.filter((r) => {
|
||||||
if (locationid && fnum(r.locationid) !== locationid) return false;
|
if (locationid && fnum(r.locationid) !== locationid) return false;
|
||||||
|
if (branch && fnum(r.applocationid) !== branch) return false;
|
||||||
if (!term) return true;
|
if (!term) return true;
|
||||||
return (
|
// Broad match across every order field shown or relevant (mirrors the ops
|
||||||
fstr(r.orderid).toLowerCase().includes(term) ||
|
// console search): id, both parties + contacts + addresses, branch, rider,
|
||||||
fstr(r.deliverycustomer).toLowerCase().includes(term) ||
|
// status, and notes.
|
||||||
fstr(r.pickupcustomer).toLowerCase().includes(term) ||
|
return [
|
||||||
fstr(r.deliveryaddress).toLowerCase().includes(term) ||
|
r.orderid, r.orderstatus, r.ordernotes, r.tenantname,
|
||||||
fstr(r.deliverysuburb).toLowerCase().includes(term)
|
r.pickupcustomer, r.pickupcontactno, r.pickupsuburb, r.pickupaddress, r.pickuplocation,
|
||||||
);
|
r.deliverycustomer, r.deliverycontactno, r.deliverysuburb, r.deliveryaddress, r.deliverylocation,
|
||||||
|
r.applocation, r.locationname, r.ridername,
|
||||||
|
].some((v) => fstr(v).toLowerCase().includes(term));
|
||||||
});
|
});
|
||||||
}, [rawRows, localSearch, searchQuery, locationid]);
|
}, [rawRows, localSearch, searchQuery, locationid, branch]);
|
||||||
|
|
||||||
|
// Footer totals across the filtered rows (parity with the ops console's
|
||||||
|
// Total Charges / Total Amount summary).
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
let cod = 0, charges = 0, amount = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
cod += fnum(r.collectionamt);
|
||||||
|
charges += fnum(r.deliverycharge) || fnum(r.deliverycharges);
|
||||||
|
amount += fnum(r.orderamount) || fnum(r.deliveryamt);
|
||||||
|
}
|
||||||
|
return { cod, charges, amount };
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const inr = (n: number) => `₹${n.toLocaleString('en-IN')}`;
|
||||||
|
|
||||||
|
// Export the currently-filtered orders to CSV (RFC-4180 quoting).
|
||||||
|
const exportCsv = () => {
|
||||||
|
const headers = ['#', 'Order ID', 'Status', 'Branch', 'Order Date', 'Pickup', 'Pickup Contact', 'Pickup Address', 'Drop', 'Drop Contact', 'Drop Address', 'Qty', 'COD', 'KMs', 'Charges', 'Amount'];
|
||||||
|
const esc = (v: unknown) => `"${fstr(v).replace(/"/g, '""')}"`;
|
||||||
|
const lines = rows.map((r, i) => [
|
||||||
|
i + 1, fstr(r.orderid) || fstr(r.orderheaderid), fstr(r.orderstatus), fstr(r.applocation) || fstr(r.locationname),
|
||||||
|
shortTime(r.orderdate || r.deliverydate), fstr(r.pickupcustomer) || fstr(r.tenantname), fstr(r.pickupcontactno),
|
||||||
|
fstr(r.pickupaddress) || fstr(r.pickupsuburb), fstr(r.deliverycustomer), fstr(r.deliverycontactno),
|
||||||
|
fstr(r.deliveryaddress) || fstr(r.deliverysuburb), fnum(r.quantity), fnum(r.collectionamt),
|
||||||
|
fnum(r.kms), fnum(r.deliverycharge) || fnum(r.deliverycharges), fnum(r.orderamount) || fnum(r.deliveryamt),
|
||||||
|
].map(esc).join(','));
|
||||||
|
const blob = new Blob([[headers.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `Orders_${status}_${fromdate}_to_${todate}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
const hasNext = rawRows.length === PAGE_SIZE;
|
const hasNext = rawRows.length === PAGE_SIZE;
|
||||||
const total = summary?.total ?? 0;
|
const total = summary?.total ?? 0;
|
||||||
const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0);
|
const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0);
|
||||||
const countFor = (key: StatusKey): number => (summary ? (summary[key] ?? 0) : 0);
|
const countFor = (key: StatusKey): number => (summary ? (summary[key] ?? 0) : 0);
|
||||||
|
|
||||||
|
// Restrained, professional palette — deep muted tones (not neon) so the KPI
|
||||||
|
// strip reads as a serious business dashboard rather than a colourful one.
|
||||||
const kpis = [
|
const kpis = [
|
||||||
{ label: 'Created Orders', value: (summary?.created ?? 0).toLocaleString('en-IN'), color: '#0ea5e9', icon: <ShoppingBag size={20} />, badge: `${pct(summary?.created ?? 0)}% of total` },
|
{ label: 'Created Orders', value: (summary?.created ?? 0).toLocaleString('en-IN'), color: '#475569', icon: <ShoppingBag size={20} />, badge: `${pct(summary?.created ?? 0)}% of total` },
|
||||||
{ label: 'Pending Orders', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#f59e0b', icon: <Clock size={20} />, badge: `${pct(summary?.pending ?? 0)}% of total` },
|
{ label: 'Pending Orders', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#9a6700', icon: <Clock size={20} />, badge: `${pct(summary?.pending ?? 0)}% of total` },
|
||||||
{ label: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} />, badge: `${pct(summary?.delivered ?? 0)}% of total` },
|
{ label: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#15803d', icon: <CheckCircle2 size={20} />, badge: `${pct(summary?.delivered ?? 0)}% of total` },
|
||||||
{ label: 'Cancelled Orders', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#ef4444', icon: <XCircle size={20} />, badge: `${pct(summary?.cancelled ?? 0)}% of total` },
|
{ label: 'Cancelled Orders', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#b42318', icon: <XCircle size={20} />, badge: `${pct(summary?.cancelled ?? 0)}% of total` },
|
||||||
];
|
];
|
||||||
|
|
||||||
const setScope = (next: Partial<{ status: StatusKey; from: string; to: string }>) => {
|
const setScope = (next: Partial<{ status: StatusKey; from: string; to: string }>) => {
|
||||||
@@ -96,6 +209,46 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
|
|||||||
setPageno(1);
|
setPageno(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Selection helpers ───────────────────────────────────────────────────────
|
||||||
|
const rowKey = (r: Row) => fstr(r.orderheaderid) || fstr(r.orderid);
|
||||||
|
const pageKeys = rows.map(rowKey);
|
||||||
|
const allSelected = pageKeys.length > 0 && pageKeys.every((k) => selected.has(k));
|
||||||
|
const toggleRow = (k: string) =>
|
||||||
|
setSelected((prev) => {
|
||||||
|
const n = new Set(prev);
|
||||||
|
if (n.has(k)) n.delete(k);
|
||||||
|
else n.add(k);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
const toggleAll = () =>
|
||||||
|
setSelected((prev) => {
|
||||||
|
const n = new Set(prev);
|
||||||
|
if (allSelected) pageKeys.forEach((k) => n.delete(k));
|
||||||
|
else pageKeys.forEach((k) => n.add(k));
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAssign = async () => {
|
||||||
|
if (!assignRiderId || selected.size === 0) return;
|
||||||
|
const toAssign = rows.filter((r) => selected.has(rowKey(r)));
|
||||||
|
const rider = riderOptions.find((o) => o.id === assignRiderId)?.label ?? 'rider';
|
||||||
|
try {
|
||||||
|
const res = await assignMut.mutateAsync({ userid: assignRiderId, orders: toAssign });
|
||||||
|
setAssignMsg(
|
||||||
|
res.failed
|
||||||
|
? `Assigned ${res.ok}/${res.total} to ${rider} · ${res.failed} failed`
|
||||||
|
: `Assigned ${res.ok} order${res.ok === 1 ? '' : 's'} to ${rider}`,
|
||||||
|
);
|
||||||
|
setSelected(new Set());
|
||||||
|
setShowSelected(false); // return to the board with the result shown in the bar
|
||||||
|
} catch {
|
||||||
|
setAssignMsg('Assignment failed — please retry.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rows currently selected (selection is always within the visible page).
|
||||||
|
const selectedRows = useMemo(() => rows.filter((r) => selected.has(rowKey(r))), [rows, selected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in duration-300">
|
<div className="animate-in fade-in duration-300">
|
||||||
<GradientHeader
|
<GradientHeader
|
||||||
@@ -132,10 +285,10 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<input type="date" value={fromdate} max={todate} onChange={(e) => setScope({ from: e.target.value })}
|
<input type="date" value={fromdate} max={todate} onChange={(e) => setScope({ from: e.target.value })}
|
||||||
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} />
|
||||||
<span style={{ color: TEXT_3 }}>→</span>
|
<span style={{ color: TEXT_3 }}>→</span>
|
||||||
<input type="date" value={todate} min={fromdate} max={ymd(today)} onChange={(e) => setScope({ to: e.target.value })}
|
<input type="date" value={todate} min={fromdate} onChange={(e) => setScope({ to: e.target.value })}
|
||||||
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
@@ -145,7 +298,9 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
|
|||||||
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
|
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
|
||||||
<div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0">
|
<div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0">
|
||||||
{STATUS_TABS.map((t) => {
|
{STATUS_TABS.map((t) => {
|
||||||
const color = statusColor(ORDER_STATUS, t.key);
|
// Single brand accent for the tab row (calmer than per-status colours);
|
||||||
|
// the per-status hue still appears on the row Status chip where it aids scanning.
|
||||||
|
const color = BRAND;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={t.key}>
|
<React.Fragment key={t.key}>
|
||||||
<Pill active={status === t.key} color={color} onClick={() => setScope({ status: t.key })} count={summaryQ.isLoading ? '·' : countFor(t.key).toLocaleString('en-IN')}>
|
<Pill active={status === t.key} color={color} onClick={() => setScope({ status: t.key })} count={summaryQ.isLoading ? '·' : countFor(t.key).toLocaleString('en-IN')}>
|
||||||
@@ -155,41 +310,110 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full lg:w-72 lg:shrink-0"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders…" /></div>
|
<div className="flex items-center gap-2 w-full lg:w-auto lg:shrink-0">
|
||||||
|
{branches.length > 1 && (
|
||||||
|
<select
|
||||||
|
value={branch}
|
||||||
|
onChange={(e) => { setBranch(Number(e.target.value)); setPageno(1); }}
|
||||||
|
title="Filter by branch / app-location"
|
||||||
|
className="rounded-full font-bold text-xs outline-none cursor-pointer shrink-0"
|
||||||
|
style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }}
|
||||||
|
>
|
||||||
|
<option value={0}>All branches</option>
|
||||||
|
{branches.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<div className="w-full lg:w-60"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders (Ctrl+K)…" inputRef={searchRef} /></div>
|
||||||
|
<button
|
||||||
|
onClick={exportCsv}
|
||||||
|
disabled={rows.length === 0}
|
||||||
|
title="Export current view to CSV"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap shrink-0"
|
||||||
|
style={{ padding: '8px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
|
||||||
|
>
|
||||||
|
<Download size={13} /> CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
|
{/* Multi-select assign bar — shown while rows are selected (or to report a result) */}
|
||||||
|
{(selected.size > 0 || assignMsg) && (
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-2xl px-4 py-3 animate-in fade-in slide-in-from-top-1 duration-200" style={{ background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}` }}>
|
||||||
|
<span className="inline-flex items-center gap-1.5 font-extrabold text-xs" style={{ color: BRAND }}>
|
||||||
|
<UserCheck size={15} /> {selected.size} selected
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={assignRiderId}
|
||||||
|
onChange={(e) => setAssignRiderId(Number(e.target.value))}
|
||||||
|
disabled={selected.size === 0}
|
||||||
|
title="Choose a rider to assign"
|
||||||
|
className="rounded-full font-bold text-xs outline-none cursor-pointer disabled:opacity-50"
|
||||||
|
style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 260 }}
|
||||||
|
>
|
||||||
|
<option value={0}>{ridersQ.isLoading ? 'Loading riders…' : riderOptions.length ? 'Select rider…' : 'No riders available'}</option>
|
||||||
|
{riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={handleAssign}
|
||||||
|
disabled={!assignRiderId || selected.size === 0 || assignMut.isPending}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
style={{ padding: '7px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
|
||||||
|
>
|
||||||
|
{assignMut.isPending ? <Loader2 size={13} className="animate-spin" /> : <UserCheck size={13} />} Assign rider
|
||||||
|
</button>
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<button onClick={() => setSelected(new Set())} className="rounded-full font-bold text-xs cursor-pointer" style={{ padding: '7px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{assignMsg && <span className="text-[11px] font-semibold ml-auto" style={{ color: TEXT_2 }}>{assignMsg}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full" style={{ minWidth: 960 }}>
|
<table className="w-full" style={{ minWidth: 960 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{['#', 'Order', 'Pickup', 'Drop', 'Qty', 'COD', 'KMs', 'Charges', 'Status', ''].map((h, i) => (
|
<th className="px-3 py-2.5 text-left" style={TH_STYLE}>
|
||||||
|
<input type="checkbox" checked={allSelected} onChange={toggleAll} disabled={rows.length === 0} aria-label="Select all orders" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} />
|
||||||
|
</th>
|
||||||
|
{['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'KMs', 'Charges', 'Status', ''].map((h, i) => (
|
||||||
<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>
|
<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ordersQ.isLoading ? (
|
{ordersQ.isLoading ? (
|
||||||
<tr><td colSpan={10} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
|
<tr><td colSpan={12} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
|
||||||
<span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading orders…</span>
|
<span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading orders…</span>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<tr><td colSpan={10} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No orders found for this status, date range, or search.</td></tr>
|
<tr><td colSpan={12} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No orders found for this status, date range, or search.</td></tr>
|
||||||
) : (
|
) : (
|
||||||
rows.map((r, i) => {
|
rows.map((r, i) => {
|
||||||
const st = fstr(r.orderstatus).toLowerCase();
|
const st = fstr(r.orderstatus).toLowerCase();
|
||||||
const cod = fnum(r.collectionamt);
|
const cod = fnum(r.collectionamt);
|
||||||
const charges = fnum(r.deliverycharge) || fnum(r.deliverycharges);
|
const charges = fnum(r.deliverycharge) || fnum(r.deliverycharges);
|
||||||
return (
|
return (
|
||||||
<tr key={fstr(r.orderid) || i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER_C}` }}
|
<tr key={fstr(r.orderid) || i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER_C}`, background: selected.has(rowKey(r)) ? tint(BRAND) : 'transparent' }}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
|
onMouseEnter={(e) => { if (!selected.has(rowKey(r))) e.currentTarget.style.background = SURFACE_ALT; }} onMouseLeave={(e) => { e.currentTarget.style.background = selected.has(rowKey(r)) ? tint(BRAND) : 'transparent'; }}>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<input type="checkbox" checked={selected.has(rowKey(r))} onChange={() => toggleRow(rowKey(r))} aria-label="Select order" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} />
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td>
|
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td>
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
|
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
|
||||||
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
|
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
|
||||||
|
<MapPin size={11} /> {fstr(r.applocation) || '—'}
|
||||||
|
</span>
|
||||||
|
{fstr(r.locationname) && <p className="text-[10px] truncate max-w-[130px]" style={{ color: TEXT_2 }}>{fstr(r.locationname)}</p>}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
|
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
|
||||||
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
|
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
|
||||||
@@ -199,9 +423,9 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
|
|||||||
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td>
|
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td>
|
||||||
<td className="px-3 py-2.5">{cod > 0 ? <MetricPill color="#ef4444">₹{cod.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: cod > 0 ? TEXT : TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'}</td>
|
||||||
<td className="px-3 py-2.5">{fnum(r.kms) ? <MetricPill color="#ef4444">{fnum(r.kms).toFixed(1)}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: fnum(r.kms) ? TEXT_2 : TEXT_3 }}>{fnum(r.kms) ? fnum(r.kms).toFixed(1) : '—'}</td>
|
||||||
<td className="px-3 py-2.5">{charges > 0 ? <MetricPill color="#10b981">₹{charges.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: charges > 0 ? TEXT : TEXT_3 }}>{charges > 0 ? `₹${charges.toLocaleString('en-IN')}` : '—'}</td>
|
||||||
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
|
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
|
||||||
<td className="px-3 py-2.5 text-right">
|
<td className="px-3 py-2.5 text-right">
|
||||||
<button onClick={() => setDetailOrder(r)} className="rounded-full font-extrabold cursor-pointer transition-colors"
|
<button onClick={() => setDetailOrder(r)} className="rounded-full font-extrabold cursor-pointer transition-colors"
|
||||||
@@ -214,6 +438,15 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Totals across the filtered rows */}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2 px-4 py-2.5 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||||
|
<span className="text-[10px] font-extrabold uppercase tracking-wider mr-auto" style={{ color: TEXT_2 }}>Totals · {rows.length} order{rows.length === 1 ? '' : 's'}</span>
|
||||||
|
{totals.cod > 0 && <TotalChip label="COD" value={inr(totals.cod)} color={TEXT_2} />}
|
||||||
|
<TotalChip label="Charges" value={inr(totals.charges)} color={TEXT_2} />
|
||||||
|
<TotalChip label="Amount" value={inr(totals.amount)} color={BRAND} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}>Page {pageno} · {rows.length} shown</span>
|
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}>Page {pageno} · {rows.length} shown</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -224,12 +457,148 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />}
|
{detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />}
|
||||||
|
|
||||||
|
{/* Right-edge floating badge — only on the Created tab and only when
|
||||||
|
MULTIPLE orders are selected (created orders are what get dispatched).
|
||||||
|
Opens the full-page review/assign view on click. */}
|
||||||
|
{status === 'created' && selected.size > 1 && !showSelected &&
|
||||||
|
createPortal(
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSelected(true)}
|
||||||
|
title={`Review & assign ${selected.size} selected order${selected.size === 1 ? '' : 's'}`}
|
||||||
|
className="group fixed right-0 z-[150] flex items-center gap-2 py-3 pl-4 pr-5 text-white font-extrabold text-xs cursor-pointer transition-all duration-200 hover:pr-7 animate-in slide-in-from-right-4"
|
||||||
|
style={{ top: '70%', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, borderTopLeftRadius: 9999, borderBottomLeftRadius: 9999, boxShadow: `0 10px 30px ${ring(BRAND)}` }}
|
||||||
|
>
|
||||||
|
<span className="relative inline-flex">
|
||||||
|
<ClipboardList size={18} />
|
||||||
|
<span className="absolute -top-2.5 -right-2.5 min-w-[17px] h-[17px] px-1 rounded-full bg-rose-500 text-[9px] font-black flex items-center justify-center ring-2 ring-white">{selected.size}</span>
|
||||||
|
</span>
|
||||||
|
<span className="max-w-0 overflow-hidden group-hover:max-w-[80px] transition-all duration-200 whitespace-nowrap">Review</span>
|
||||||
|
</button>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSelected &&
|
||||||
|
createPortal(
|
||||||
|
<SelectedOrdersPage
|
||||||
|
rows={selectedRows}
|
||||||
|
rowKey={rowKey}
|
||||||
|
riderOptions={riderOptions}
|
||||||
|
ridersLoading={ridersQ.isLoading}
|
||||||
|
assignRiderId={assignRiderId}
|
||||||
|
setAssignRiderId={setAssignRiderId}
|
||||||
|
assigning={assignMut.isPending}
|
||||||
|
assignMsg={assignMsg}
|
||||||
|
onAssign={handleAssign}
|
||||||
|
onRemove={(k) => toggleRow(k)}
|
||||||
|
onClose={() => setShowSelected(false)}
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIVIDER_C = '#f1f5f9';
|
const DIVIDER_C = '#f1f5f9';
|
||||||
|
|
||||||
|
// ── Selected-orders review page (opened from the right-edge floating badge) ──────
|
||||||
|
function SelectedOrdersPage({
|
||||||
|
rows, rowKey, riderOptions, ridersLoading, assignRiderId, setAssignRiderId, assigning, assignMsg, onAssign, onRemove, onClose,
|
||||||
|
}: {
|
||||||
|
rows: Row[];
|
||||||
|
rowKey: (r: Row) => string;
|
||||||
|
riderOptions: { id: number; label: string }[];
|
||||||
|
ridersLoading: boolean;
|
||||||
|
assignRiderId: number;
|
||||||
|
setAssignRiderId: (n: number) => void;
|
||||||
|
assigning: boolean;
|
||||||
|
assignMsg: string;
|
||||||
|
onAssign: () => void;
|
||||||
|
onRemove: (k: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[200] overflow-y-auto animate-in fade-in duration-200" style={{ background: '#f8fafc' }}>
|
||||||
|
{/* Sticky page header with the assign controls */}
|
||||||
|
<div className="sticky top-0 z-10 border-b" style={{ background: '#fff', borderColor: BORDER }}>
|
||||||
|
<div className="max-w-5xl mx-auto px-4 md:px-8 py-4 flex flex-wrap items-center gap-3">
|
||||||
|
<button onClick={onClose} className="inline-flex items-center gap-1.5 rounded-full font-bold text-xs cursor-pointer" style={{ padding: '8px 14px', border: `1px solid ${BORDER}`, color: TEXT_2, background: '#fff' }}>
|
||||||
|
<ArrowLeft size={14} /> Back to orders
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-9 w-9 rounded-xl flex items-center justify-center" style={{ background: tint(BRAND), color: BRAND }}><ClipboardList size={18} /></span>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-bold text-lg tracking-tight leading-none" style={{ color: TEXT }}>Selected Orders</h1>
|
||||||
|
<p className="text-[11px] mt-1" style={{ color: TEXT_2 }}>{rows.length} order{rows.length === 1 ? '' : 's'} ready to assign</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
<select value={assignRiderId} onChange={(e) => setAssignRiderId(Number(e.target.value))} className="rounded-full font-bold text-xs outline-none cursor-pointer" style={{ padding: '8px 12px', border: `1.5px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 260 }}>
|
||||||
|
<option value={0}>{ridersLoading ? 'Loading riders…' : riderOptions.length ? 'Select rider…' : 'No riders available'}</option>
|
||||||
|
{riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<button onClick={onAssign} disabled={!assignRiderId || rows.length === 0 || assigning} className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed" style={{ padding: '8px 16px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}>
|
||||||
|
{assigning ? <Loader2 size={14} className="animate-spin" /> : <UserCheck size={14} />} Assign rider
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6">
|
||||||
|
{assignMsg && <div className="mb-4 rounded-xl px-4 py-2.5 text-xs font-semibold" style={{ background: tint(BRAND), border: `1px solid ${edge(BRAND)}`, color: BRAND }}>{assignMsg}</div>}
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="bg-white border rounded-2xl p-12 text-center text-xs" style={{ borderColor: BORDER, color: TEXT_3 }}>
|
||||||
|
No orders selected. <button onClick={onClose} className="font-bold underline cursor-pointer" style={{ color: BRAND }}>Go back</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full" style={{ minWidth: 720 }}>
|
||||||
|
<thead><tr>{['#', 'Order', 'Pickup', 'Drop', 'Status', ''].map((h, i) => <th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, i) => {
|
||||||
|
const st = fstr(r.orderstatus).toLowerCase();
|
||||||
|
return (
|
||||||
|
<tr key={rowKey(r) || i} style={{ borderBottom: `1px solid ${DIVIDER_C}` }}>
|
||||||
|
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
|
||||||
|
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="font-bold text-[12px] truncate max-w-[180px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
|
||||||
|
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="font-bold text-[12px] truncate max-w-[180px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
|
||||||
|
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
|
||||||
|
<td className="px-3 py-2.5 text-right">
|
||||||
|
<button onClick={() => onRemove(rowKey(r))} title="Remove from selection" className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={15} /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TotalChip({ label, value, color }: { label: string; value: string; color: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full font-bold" style={{ padding: '4px 11px', fontSize: 11.5, background: soft(color), color, border: `1px solid ${edge(color)}` }}>
|
||||||
|
<span className="uppercase tracking-wider text-[9px] font-extrabold opacity-80">{label}</span>
|
||||||
|
<span className="font-mono">{value}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) {
|
function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button onClick={onClick} disabled={disabled}
|
<button onClick={onClick} disabled={disabled}
|
||||||
@@ -252,10 +621,15 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
|
|||||||
const st = fstr(order.orderstatus).toLowerCase();
|
const st = fstr(order.orderstatus).toLowerCase();
|
||||||
const total = fnum(order.deliveryamt) || fnum(order.orderamount);
|
const total = fnum(order.deliveryamt) || fnum(order.orderamount);
|
||||||
|
|
||||||
return (
|
// Portal to <body> so the overlay escapes any transformed / blurred / overflow
|
||||||
|
// ancestor in the view tree — otherwise `fixed inset-0` resolves against that
|
||||||
|
// ancestor (not the viewport) and the panel collapses to a sliver. The explicit
|
||||||
|
// viewport-relative width is a belt-and-suspenders so sizing never depends on
|
||||||
|
// percentage resolution against a broken containing block.
|
||||||
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }}
|
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }}
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
<div className="bg-white w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200" style={{ border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
|
<div className="bg-white max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
|
||||||
<div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${soft(BRAND)} 100%)` }} />
|
<div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${soft(BRAND)} 100%)` }} />
|
||||||
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||||
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Package size={16} style={{ color: BRAND }} /> Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}</h4>
|
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Package size={16} style={{ color: BRAND }} /> Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}</h4>
|
||||||
@@ -294,7 +668,8 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
|
|||||||
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT_LOCAL})` }}>Close</button>
|
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT_LOCAL})` }}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,12 +38,13 @@ interface ReportsViewProps {
|
|||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
isCoimbatoreView: boolean;
|
isCoimbatoreView: boolean;
|
||||||
setIsCoimbatoreView: (val: boolean) => void;
|
setIsCoimbatoreView: (val: boolean) => void;
|
||||||
|
tenantId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MONTH_KEYS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dece'];
|
const MONTH_KEYS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dece'];
|
||||||
const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView }: ReportsViewProps) {
|
export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView, tenantId = FIESTA_TENANT_ID }: ReportsViewProps) {
|
||||||
const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)');
|
const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)');
|
||||||
const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all');
|
const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all');
|
||||||
const [stockFilter, setStockFilter] = useState<'All' | 'Healthy' | 'Low Stock' | 'Critical'>('All');
|
const [stockFilter, setStockFilter] = useState<'All' | 'Healthy' | 'Low Stock' | 'Critical'>('All');
|
||||||
@@ -87,12 +88,12 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
const prevEnd = new Date(yearStart.getTime() - 86400000);
|
const prevEnd = new Date(yearStart.getTime() - 86400000);
|
||||||
const prevStart = new Date(prevEnd.getTime() - periodDays * 86400000);
|
const prevStart = new Date(prevEnd.getTime() - periodDays * 86400000);
|
||||||
|
|
||||||
const summaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, ymd(yearStart), todate);
|
const summaryQ = useFiestaOrderSummary(tenantId, ymd(yearStart), todate);
|
||||||
const prevSummaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, ymd(prevStart), ymd(prevEnd));
|
const prevSummaryQ = useFiestaOrderSummary(tenantId, ymd(prevStart), ymd(prevEnd));
|
||||||
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
const locSummaryQ = useFiestaLocationSummary(tenantId);
|
||||||
const insightQ = useFiestaOrderInsight(FIESTA_TENANT_ID);
|
const insightQ = useFiestaOrderInsight(tenantId);
|
||||||
const stockQ = useFiestaStockStatement({
|
const stockQ = useFiestaStockStatement({
|
||||||
tenantid: FIESTA_TENANT_ID,
|
tenantid: tenantId,
|
||||||
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||||
keyword: '',
|
keyword: '',
|
||||||
pageno: 1,
|
pageno: 1,
|
||||||
@@ -652,7 +653,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
{chartMetric === 'orders' ? 'Total Orders Velocity Trend' :
|
{chartMetric === 'orders' ? 'Total Orders Velocity Trend' :
|
||||||
chartMetric === 'revenue' ? 'Revenue Expansion Trajectory' :
|
chartMetric === 'revenue' ? 'Revenue Expansion Trajectory' :
|
||||||
chartMetric === 'cancelled' ? 'Order Cancellation Frequency' :
|
chartMetric === 'cancelled' ? 'Order Cancellation Frequency' :
|
||||||
'Catalog Active SKUs Growth'}
|
'Catalogue Active SKUs Growth'}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,19 +14,17 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
Phone,
|
Phone,
|
||||||
Mail,
|
Mail,
|
||||||
Plus
|
Plus,
|
||||||
|
Bike
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries';
|
import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||||
import { useAppRoles } from '../services/queries';
|
import { useAppRoles } from '../services/queries';
|
||||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||||
import UsersPanel from './UsersPanel';
|
import UsersPanel from './UsersPanel';
|
||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
|
import AdminConsole from './AdminConsole';
|
||||||
|
|
||||||
interface SettingsViewProps {
|
type TabKey = 'profile' | 'outlets' | 'users';
|
||||||
tenantId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TabKey = 'profile' | 'outlets' | 'users' | 'delivery' | 'payment' | 'preferences';
|
|
||||||
|
|
||||||
/** Locally-persisted merchant preferences (survive reload via localStorage). */
|
/** Locally-persisted merchant preferences (survive reload via localStorage). */
|
||||||
interface MerchantSettings {
|
interface MerchantSettings {
|
||||||
@@ -138,6 +136,13 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
|||||||
// (see [R6]) so they are not persisted; the operational controls that would
|
// (see [R6]) so they are not persisted; the operational controls that would
|
||||||
// need persistence show an AwaitingApi notice instead of saving silently.
|
// need persistence show an AwaitingApi notice instead of saving silently.
|
||||||
const [form, setForm] = useState<MerchantSettings>({ ...DEFAULTS });
|
const [form, setForm] = useState<MerchantSettings>({ ...DEFAULTS });
|
||||||
|
const [showStoreOnboarding, setShowStoreOnboarding] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'outlets') {
|
||||||
|
setShowStoreOnboarding(false);
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
// First-run seeding: fill region/role defaults from the live tenant once it
|
// First-run seeding: fill region/role defaults from the live tenant once it
|
||||||
// arrives (used at runtime by the Add User dialog / region label).
|
// arrives (used at runtime by the Add User dialog / region label).
|
||||||
@@ -177,9 +182,6 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
|||||||
{ key: 'profile', label: 'Business Profile', icon: Building2 },
|
{ key: 'profile', label: 'Business Profile', icon: Building2 },
|
||||||
{ key: 'outlets', label: 'Outlets', icon: Store },
|
{ key: 'outlets', label: 'Outlets', icon: Store },
|
||||||
{ key: 'users', label: 'Users & Access', icon: Users },
|
{ key: 'users', label: 'Users & Access', icon: Users },
|
||||||
{ key: 'delivery', label: 'Delivery', icon: Truck },
|
|
||||||
{ key: 'payment', label: 'Payment & Tax', icon: CreditCard },
|
|
||||||
{ key: 'preferences', label: 'Preferences', icon: SlidersHorizontal },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Build role options from the live app-roles API; fall back to the known
|
// Build role options from the live app-roles API; fall back to the known
|
||||||
@@ -392,14 +394,29 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
|||||||
<div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-md animate-in fade-in duration-200">
|
<div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-md animate-in fade-in duration-200">
|
||||||
<div className="flex justify-between items-center pb-4 border-b border-slate-100">
|
<div className="flex justify-between items-center pb-4 border-b border-slate-100">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest block">Our Stores</span>
|
<span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">
|
||||||
<h2 className="text-xl font-bold text-slate-900 mt-1">Store Directory</h2>
|
{showStoreOnboarding ? 'Onboarding' : 'Our Stores'}
|
||||||
</div>
|
|
||||||
<span className="text-xs text-[#581c87] font-bold bg-purple-50 px-3.5 py-1.5 rounded-full border border-purple-100/50">
|
|
||||||
{locationsQ.isLoading ? 'Loading…' : `${cleanOutlets.length} outlet${cleanOutlets.length === 1 ? '' : 's'}`}
|
|
||||||
</span>
|
</span>
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 mt-1">
|
||||||
|
{showStoreOnboarding ? 'Add Store Outlet Location' : 'Store Directory'}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowStoreOnboarding(!showStoreOnboarding)}
|
||||||
|
className="bg-[#581c87] hover:bg-purple-800 text-white px-4 py-2.5 rounded-xl text-xs font-bold uppercase tracking-wider flex items-center gap-1.5 cursor-pointer shadow-sm active:scale-95 transition-all border-none"
|
||||||
|
>
|
||||||
|
{showStoreOnboarding ? 'View Store Directory' : '+ Add Store Branch'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showStoreOnboarding ? (
|
||||||
|
<div className="pt-2">
|
||||||
|
<AdminConsole activeTab="store" showHeader={false} onBack={() => setShowStoreOnboarding(false)} tenantId={tenantId} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{locationsQ.isLoading ? (
|
{locationsQ.isLoading ? (
|
||||||
<div className="text-center py-lg text-slate-400 font-medium text-sm">Loading live outlets…</div>
|
<div className="text-center py-lg text-slate-400 font-medium text-sm">Loading live outlets…</div>
|
||||||
) : cleanOutlets.length === 0 ? (
|
) : cleanOutlets.length === 0 ? (
|
||||||
@@ -435,7 +452,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
|||||||
{/* Outlet Details Grid */}
|
{/* Outlet Details Grid */}
|
||||||
<div className="grid grid-cols-2 gap-3 bg-slate-50/50 p-3.5 rounded-xl border border-slate-100/80">
|
<div className="grid grid-cols-2 gap-3 bg-slate-50/50 p-3.5 rounded-xl border border-slate-100/80">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] text-slate-450 uppercase font-bold block">Delivery Range</span>
|
<span className="text-[10px] text-slate-455 uppercase font-bold block">Delivery Range</span>
|
||||||
<p className="font-bold text-slate-700 text-xs">
|
<p className="font-bold text-slate-700 text-xs">
|
||||||
{loc.deliveryradius ? `Up to ${loc.deliveryradius / 1000} km` : '—'}
|
{loc.deliveryradius ? `Up to ${loc.deliveryradius / 1000} km` : '—'}
|
||||||
</p>
|
</p>
|
||||||
@@ -461,6 +478,8 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -468,100 +487,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
|||||||
<UsersPanel tenantId={tenantId} defaultNewUserRole={form.defaultNewUserRole} />
|
<UsersPanel tenantId={tenantId} defaultNewUserRole={form.defaultNewUserRole} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'delivery' && (
|
|
||||||
<div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-lg animate-in fade-in duration-200">
|
|
||||||
<div>
|
|
||||||
<span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">Delivery</span>
|
|
||||||
<h2 className="text-xl font-bold text-slate-900 mt-1">Order Prep, Timings & Dispatch</h2>
|
|
||||||
</div>
|
|
||||||
{/* No merchant-settings API yet — these operational controls cannot be persisted live. */}
|
|
||||||
<AwaitingApi label="Merchant settings persistence" api="[R6]" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'payment' && (
|
|
||||||
<div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-lg animate-in fade-in duration-200">
|
|
||||||
<div>
|
|
||||||
<span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">Payment & Tax</span>
|
|
||||||
<h2 className="text-xl font-bold text-slate-900 mt-1">Checkout & Taxation</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Live (read-only) tenant payment details. */}
|
|
||||||
<div className="space-y-sm">
|
|
||||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
|
||||||
Store Payment Details
|
|
||||||
</span>
|
|
||||||
<div className="divide-y divide-slate-100/70 mt-2">
|
|
||||||
<Row title="Minimum Order Value" desc="Smallest order a customer can place (from store profile).">
|
|
||||||
<span className="font-bold text-slate-700 text-sm font-mono">
|
|
||||||
{tenant && fnum(tenant.minorder) ? `₹${fnum(tenant.minorder).toLocaleString('en-IN')}` : '—'}
|
|
||||||
</span>
|
|
||||||
</Row>
|
|
||||||
<Row title="Payment Gateway ID" desc="Configured payment type for this store.">
|
|
||||||
<span className="font-mono font-black bg-purple-100 px-3 py-1.5 rounded-xl border border-purple-200/40 text-xs">
|
|
||||||
{tenant && fnum(tenant.paymenttype) ? fnum(tenant.paymenttype) : '—'}
|
|
||||||
</span>
|
|
||||||
</Row>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Editable checkout gateways + tax rules have no persistence backend. */}
|
|
||||||
<div className="space-y-sm">
|
|
||||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
|
||||||
Checkout Gateways & Taxation
|
|
||||||
</span>
|
|
||||||
<AwaitingApi className="mt-2" label="Merchant settings persistence" api="[R6]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'preferences' && (
|
|
||||||
<div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-lg animate-in fade-in duration-200">
|
|
||||||
{/* Group 1: General Defaults */}
|
|
||||||
<div className="space-y-sm">
|
|
||||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
|
||||||
General Defaults
|
|
||||||
</span>
|
|
||||||
<div className="divide-y divide-slate-100/70 mt-2">
|
|
||||||
<Row title="Default Region" desc="Region applied to new outlets and reports.">
|
|
||||||
<div className="relative rounded-xl shadow-sm">
|
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
|
||||||
<MapPin size={14} className="text-slate-400" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.defaultRegion}
|
|
||||||
onChange={(e) => set('defaultRegion', e.target.value)}
|
|
||||||
className="w-44 pl-8 pr-4 py-2 border border-slate-200 rounded-xl font-bold text-slate-700 bg-slate-50/40 hover:bg-slate-50 focus:bg-white outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/10 transition-all text-sm text-right"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
<Row title="Default Role for New Users" desc="Pre-selected role in the Add User dialog.">
|
|
||||||
<select
|
|
||||||
value={form.defaultNewUserRole}
|
|
||||||
onChange={(e) => set('defaultNewUserRole', Number(e.target.value))}
|
|
||||||
className="border border-slate-200 bg-slate-50/40 hover:bg-slate-50 focus:bg-white rounded-xl py-2 px-3 font-bold text-slate-700 outline-none cursor-pointer focus:border-purple-500 transition-all text-sm shadow-sm"
|
|
||||||
>
|
|
||||||
{roleOptions.map((r) => (
|
|
||||||
<option key={r.id} value={r.id}>{r.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Row>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-slate-400 font-medium mt-2 px-4">
|
|
||||||
Region and default-role are in-session workspace preferences applied at runtime; they are not saved to a backend.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Group 2: Notifications, sync interval & sandbox — no persistence backend. */}
|
|
||||||
<div className="space-y-sm">
|
|
||||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
|
||||||
Notifications, Sync & Test Mode
|
|
||||||
</span>
|
|
||||||
<AwaitingApi className="mt-2" label="Merchant settings persistence" api="[R6]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
Store,
|
Store,
|
||||||
Layers,
|
Layers,
|
||||||
Settings,
|
Settings,
|
||||||
TrendingUp
|
TrendingUp,
|
||||||
|
ShieldAlert
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { MainSection } from '../types';
|
import { MainSection } from '../types';
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ interface SidebarProps {
|
|||||||
isCoimbatoreView: boolean;
|
isCoimbatoreView: boolean;
|
||||||
setIsCoimbatoreView: (val: boolean) => void;
|
setIsCoimbatoreView: (val: boolean) => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({
|
export default function Sidebar({
|
||||||
@@ -26,20 +28,21 @@ export default function Sidebar({
|
|||||||
setCurrentSection,
|
setCurrentSection,
|
||||||
isCoimbatoreView,
|
isCoimbatoreView,
|
||||||
setIsCoimbatoreView,
|
setIsCoimbatoreView,
|
||||||
isOpen
|
isOpen,
|
||||||
|
isAdmin
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
// Navigation elements
|
// Navigation elements
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
|
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ id: 'stores' as MainSection, label: 'Stores', icon: Store },
|
{ id: 'stores' as MainSection, label: 'Stores', icon: Store },
|
||||||
{ id: 'inventory' as MainSection, label: 'Product Catalog', icon: Layers },
|
{ id: 'inventory' as MainSection, label: 'Product Catalogue', icon: Layers },
|
||||||
{ id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp },
|
{ id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp },
|
||||||
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
|
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={`fixed left-0 top-0 h-screen bg-[#581c87] border-r border-[#4c1d95] text-white flex-col py-xl pt-24 z-40 hidden md:flex transition-all duration-300 ${
|
className={`fixed left-0 top-0 h-screen bg-[#581c87] border-r border-[#4c1d95] text-white flex-col py-xl pt-20 z-40 hidden md:flex transition-all duration-300 ${
|
||||||
isOpen ? 'w-64' : 'w-20'
|
isOpen ? 'w-64' : 'w-20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -70,3 +73,4 @@ export default function Sidebar({
|
|||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,57 +4,35 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inventory & Catalog — the store user's page.
|
* Inventory & Catalogue — the store user's page.
|
||||||
*
|
*
|
||||||
* Flow: the manager curates an assortment from the global catalog; the store user
|
* Product-management flow (3 tiers):
|
||||||
* sees ONLY that manager-selected catalog (never the global one) and chooses which
|
* 1. Admin adds products to the GLOBAL catalogue and selects which ones (+ qty)
|
||||||
* products to stock in their own store. Two tabs:
|
* to publish — that's the shared "store catalogue" (services/storeCatalogue).
|
||||||
* • Browse Catalog — the manager-approved products, each addable to the store.
|
* 2. The user sees ONLY that admin-curated catalogue here (never the global one)
|
||||||
* • My Store Inventory — what's currently stocked at this outlet (live stock).
|
* and chooses which products they need, each with their own quantity.
|
||||||
|
* 3. Those picks are the user's request for their store.
|
||||||
*
|
*
|
||||||
* The "manager-selected catalog" is sourced from the tenant master catalog
|
* The catalogue source is the shared store catalogue (localStorage bridge for now;
|
||||||
* (getMasterCatalog) for now — see CATALOG_SOURCE below; swap that one hook for
|
* backend: GET /products/getlocationproducts). The user's picks persist per store
|
||||||
* the approved-products endpoint once it exists.
|
* and `commitSelectionToStore()` is the single backend integration point
|
||||||
*
|
* (POST /products/createproductlocation / a stock-request endpoint).
|
||||||
* Stocking a product at a location needs a write endpoint that isn't built yet,
|
|
||||||
* so selections are kept locally (persisted per store) and marked "pending sync".
|
|
||||||
* `commitSelectionToStore()` is the single integration point: replace its body
|
|
||||||
* with the real mutation when the backend is ready.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch } from 'lucide-react';
|
||||||
Search, Boxes, Layers, Plus, Check, CheckCircle2, X, Tag, Store, PackageSearch, AlertTriangle,
|
import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
|
||||||
useFiestaMasterCatalog,
|
|
||||||
useFiestaStockStatement,
|
|
||||||
useFiestaProductCategories,
|
|
||||||
useFiestaProductSubcategories,
|
|
||||||
FIESTA_TENANT_ID,
|
|
||||||
} from '../services/fiestaQueries';
|
|
||||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||||
import { categoryName } from '../services/fiestaMappers';
|
import { categoryName } from '../services/fiestaMappers';
|
||||||
|
import { useStoreCatalogue } from '../services/storeCatalogue';
|
||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
|
|
||||||
const BRAND = '#581c87';
|
|
||||||
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
||||||
|
|
||||||
interface StoreCatalogViewProps {
|
interface StoreCatalogViewProps {
|
||||||
locationid?: number;
|
locationid?: number;
|
||||||
storeName?: string;
|
storeName?: string;
|
||||||
}
|
tenantId?: number;
|
||||||
|
|
||||||
interface CatalogProduct {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
image: string;
|
|
||||||
category: string;
|
|
||||||
categoryid: number;
|
|
||||||
subcategoryid: number;
|
|
||||||
subcategoryname: string;
|
|
||||||
price: number;
|
|
||||||
unit: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stockStatus(closing: number): { label: string; color: string } {
|
function stockStatus(closing: number): { label: string; color: string } {
|
||||||
@@ -64,56 +42,68 @@ function stockStatus(closing: number): { label: string; color: string } {
|
|||||||
return { label: 'Healthy', color: '#10b981' };
|
return { label: 'Healthy', color: '#10b981' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StoreCatalogView({ locationid, storeName = 'your store' }: StoreCatalogViewProps) {
|
/** Category → pill badge classes (mirrors the admin Global Catalogue card). */
|
||||||
const tenantid = FIESTA_TENANT_ID;
|
function catBadgeClass(category: string): string {
|
||||||
const [view, setView] = useState<'catalog' | 'inventory'>('catalog');
|
const c = category.toLowerCase();
|
||||||
|
if (c.startsWith('staple')) return 'bg-amber-50 text-amber-600 border border-amber-100';
|
||||||
|
if (c.includes('grocer')) return 'bg-emerald-50 text-emerald-600 border border-emerald-100';
|
||||||
|
if (c.includes('beverage')) return 'bg-sky-50 text-sky-600 border border-sky-100';
|
||||||
|
return 'bg-rose-50 text-rose-600 border border-rose-100';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID }: StoreCatalogViewProps) {
|
||||||
|
const tenantid = tenantId;
|
||||||
|
const [view, setView] = useState<'catalogue' | 'inventory'>('catalogue');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [categoryid, setCategoryid] = useState(0);
|
const [category, setCategory] = useState('ALL');
|
||||||
const [subcategoryid, setSubcategoryid] = useState(0);
|
|
||||||
const [notice, setNotice] = useState(false);
|
const [notice, setNotice] = useState(false);
|
||||||
|
|
||||||
// Selections "to stock at this store" — persisted per outlet so choices survive
|
// The admin-curated catalogue (what the user is allowed to pick from).
|
||||||
// a refresh until the backend write exists.
|
const storeCat = useStoreCatalogue();
|
||||||
const storageKey = `nearledaily.catalog.selected.${locationid ?? 'na'}`;
|
const products = useMemo(
|
||||||
const [selected, setSelected] = useState<Set<string>>(() => {
|
() =>
|
||||||
|
storeCat.items.map((it) => ({
|
||||||
|
id: it.productid,
|
||||||
|
name: it.name,
|
||||||
|
sku: it.sku || `SKU-${it.productid}`,
|
||||||
|
image: it.image || PLACEHOLDER,
|
||||||
|
category: it.category || 'General',
|
||||||
|
price: it.price,
|
||||||
|
unit: it.unit,
|
||||||
|
adminQty: it.qty,
|
||||||
|
})),
|
||||||
|
[storeCat.items],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The user's picks: productid → quantity they need. Persisted per store.
|
||||||
|
const storageKey = `nearledaily.catalogue.request.${locationid ?? 'na'}`;
|
||||||
|
const [picks, setPicks] = useState<Record<string, number>>(() => {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(storageKey);
|
const raw = localStorage.getItem(storageKey);
|
||||||
return new Set(raw ? (JSON.parse(raw) as string[]) : []);
|
return raw ? (JSON.parse(raw) as Record<string, number>) : {};
|
||||||
} catch {
|
} catch {
|
||||||
return new Set();
|
return {};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try { localStorage.setItem(storageKey, JSON.stringify([...selected])); } catch { /* ignore */ }
|
try { localStorage.setItem(storageKey, JSON.stringify(picks)); } catch { /* ignore */ }
|
||||||
}, [selected, storageKey]);
|
}, [picks, storageKey]);
|
||||||
|
|
||||||
// ── Data ──────────────────────────────────────────────────────────────────────
|
const togglePick = (id: string) => {
|
||||||
// CATALOG_SOURCE: the manager-selected assortment. Swap this hook for the
|
setNotice(false);
|
||||||
// approved-products endpoint when it's available; the rest of the page is agnostic.
|
setPicks((prev) => {
|
||||||
const catalogQ = useFiestaMasterCatalog({ tenantid, subcategoryid: subcategoryid || undefined, pagesize: 200 });
|
const next = { ...prev };
|
||||||
|
if (next[id] != null) delete next[id];
|
||||||
|
else next[id] = 1;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const setPickQty = (id: string, qty: number) => setPicks((prev) => ({ ...prev, [id]: Math.max(1, Math.round(qty) || 1) }));
|
||||||
|
const pickCount = Object.keys(picks).length;
|
||||||
|
|
||||||
|
// Store inventory (live stock) for the "My Store Inventory" tab + "In Store" tags.
|
||||||
const stockQ = useFiestaStockStatement({ tenantid, locationid: locationid ?? 0, pagesize: 200 });
|
const stockQ = useFiestaStockStatement({ tenantid, locationid: locationid ?? 0, pagesize: 200 });
|
||||||
const categoriesQ = useFiestaProductCategories();
|
|
||||||
const subcategoriesQ = useFiestaProductSubcategories({ categoryid, tenantid });
|
|
||||||
|
|
||||||
const products = useMemo<CatalogProduct[]>(
|
|
||||||
() =>
|
|
||||||
(catalogQ.data ?? []).map((r: Row) => ({
|
|
||||||
id: fstr(r.productid) || fstr(r.productname),
|
|
||||||
name: fstr(r.productname) || 'Unnamed product',
|
|
||||||
image: fstr(r.productimage) || PLACEHOLDER,
|
|
||||||
category: categoryName(fnum(r.categoryid)),
|
|
||||||
categoryid: fnum(r.categoryid),
|
|
||||||
subcategoryid: fnum(r.subcategoryid),
|
|
||||||
subcategoryname: fstr(r.subcategoryname),
|
|
||||||
price: fnum(r.retailprice) || fnum(r.productcost),
|
|
||||||
unit: `${fstr(r.productunit) || 'unit'} · ${fstr(r.unitvalue) || '1'}`,
|
|
||||||
})),
|
|
||||||
[catalogQ.data],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Products already stocked at this store (by productid) — drives the "In Store" state.
|
|
||||||
const inStore = useMemo(() => new Set((stockQ.data ?? []).map((r) => fstr(r.productid))), [stockQ.data]);
|
const inStore = useMemo(() => new Set((stockQ.data ?? []).map((r) => fstr(r.productid))), [stockQ.data]);
|
||||||
|
|
||||||
const inventory = useMemo(
|
const inventory = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(stockQ.data ?? []).map((r: Row) => {
|
(stockQ.data ?? []).map((r: Row) => {
|
||||||
@@ -121,6 +111,8 @@ export default function StoreCatalogView({ locationid, storeName = 'your store'
|
|||||||
return {
|
return {
|
||||||
id: fstr(r.productid),
|
id: fstr(r.productid),
|
||||||
name: fstr(r.productname) || 'Unnamed product',
|
name: fstr(r.productname) || 'Unnamed product',
|
||||||
|
sku: fstr(r.sku) || `SKU-${fstr(r.productid)}`,
|
||||||
|
image: fstr(r.productimage) || PLACEHOLDER,
|
||||||
category: categoryName(fnum(r.categoryid)),
|
category: categoryName(fnum(r.categoryid)),
|
||||||
closing,
|
closing,
|
||||||
...stockStatus(closing),
|
...stockStatus(closing),
|
||||||
@@ -128,78 +120,46 @@ export default function StoreCatalogView({ locationid, storeName = 'your store'
|
|||||||
}),
|
}),
|
||||||
[stockQ.data],
|
[stockQ.data],
|
||||||
);
|
);
|
||||||
|
const filteredInventory = useMemo(() => {
|
||||||
|
const term = search.toLowerCase();
|
||||||
|
if (!term) return inventory;
|
||||||
|
return inventory.filter((it) => it.name.toLowerCase().includes(term) || it.category.toLowerCase().includes(term) || it.id.toLowerCase().includes(term));
|
||||||
|
}, [inventory, search]);
|
||||||
|
|
||||||
|
const categories = useMemo(() => [...new Set(products.map((p) => p.category))].sort(), [products]);
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const term = search.toLowerCase();
|
const term = search.toLowerCase();
|
||||||
return products.filter((p) => {
|
return products.filter((p) => {
|
||||||
if (categoryid && p.categoryid !== categoryid) return false;
|
if (category !== 'ALL' && p.category !== category) return false;
|
||||||
if (!term) return true;
|
if (!term) return true;
|
||||||
return p.name.toLowerCase().includes(term) || p.category.toLowerCase().includes(term) || p.id.toLowerCase().includes(term);
|
return p.name.toLowerCase().includes(term) || p.category.toLowerCase().includes(term) || p.id.toLowerCase().includes(term);
|
||||||
});
|
});
|
||||||
}, [products, search, categoryid]);
|
}, [products, search, category]);
|
||||||
|
|
||||||
// Categories come from the Fiesta product-categories endpoint; if it returns
|
|
||||||
// nothing, fall back to the categories present in the loaded catalog so the
|
|
||||||
// filter is never empty.
|
|
||||||
const categories = useMemo(() => {
|
|
||||||
const fromApi = (categoriesQ.data ?? [])
|
|
||||||
.map((c) => ({ id: fnum(c.categoryid), name: fstr(c.categoryname) || categoryName(fnum(c.categoryid)) }))
|
|
||||||
.filter((c) => c.id);
|
|
||||||
if (fromApi.length) return fromApi;
|
|
||||||
const seen = new Map<number, string>();
|
|
||||||
for (const p of products) if (p.categoryid && !seen.has(p.categoryid)) seen.set(p.categoryid, p.category);
|
|
||||||
return [...seen.entries()].map(([id, name]) => ({ id, name }));
|
|
||||||
}, [categoriesQ.data, products]);
|
|
||||||
// Subcategories: Fiesta endpoint as source of truth; fall back to the
|
|
||||||
// subcategories present in the loaded catalog for the selected category.
|
|
||||||
const subcategories = useMemo(() => {
|
|
||||||
const fromApi = (subcategoriesQ.data ?? [])
|
|
||||||
.map((s) => ({ id: fnum(s.subcategoryid), name: fstr(s.subcategoryname) || `Subcategory ${fnum(s.subcategoryid)}` }))
|
|
||||||
.filter((s) => s.id);
|
|
||||||
if (fromApi.length) return fromApi;
|
|
||||||
const seen = new Map<number, string>();
|
|
||||||
for (const p of products) {
|
|
||||||
if (categoryid && p.categoryid !== categoryid) continue;
|
|
||||||
if (p.subcategoryid && !seen.has(p.subcategoryid)) seen.set(p.subcategoryid, p.subcategoryname || `Subcategory ${p.subcategoryid}`);
|
|
||||||
}
|
|
||||||
return [...seen.entries()].map(([id, name]) => ({ id, name }));
|
|
||||||
}, [subcategoriesQ.data, products, categoryid]);
|
|
||||||
|
|
||||||
const toggle = (id: string) => {
|
|
||||||
setNotice(false);
|
|
||||||
setSelected((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.has(id) ? next.delete(id) : next.add(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Integration point ──────────────────────────────────────────────────────────
|
// ── Integration point ──────────────────────────────────────────────────────────
|
||||||
// Replace this body with the real mutation: POST the selected product ids to the
|
// Replace with the real request/stock POST (selected productids + quantities),
|
||||||
// store/location assortment (stock-entry) endpoint, then invalidate stockQ.
|
// then invalidate stockQ.
|
||||||
const commitSelectionToStore = () => {
|
const commitSelectionToStore = () => setNotice(true);
|
||||||
setNotice(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-24">
|
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-28">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">Inventory & Catalog</h1>
|
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">Product Catalogue</h1>
|
||||||
<p className="text-zinc-500 text-xs mt-1">
|
<p className="text-zinc-500 text-xs mt-1">
|
||||||
Browse the products approved for your store and choose what to stock at <span className="font-semibold text-[#581c87]">{storeName}</span>.
|
Products your admin published for <span className="font-semibold text-[#581c87]">{storeName}</span> — choose what you need and set quantities.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex items-center gap-1 bg-zinc-100/80 p-1 rounded-xl border border-zinc-200/60 w-full sm:w-auto sm:inline-flex">
|
<div className="flex items-center gap-1 bg-zinc-100/80 p-1 rounded-xl border border-zinc-200/60 w-full sm:w-auto sm:inline-flex">
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('catalog')}
|
onClick={() => setView('catalogue')}
|
||||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-xs font-bold transition-all ${
|
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-xs font-bold transition-all ${
|
||||||
view === 'catalog' ? 'bg-white text-[#581c87] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
view === 'catalogue' ? 'bg-white text-[#581c87] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Boxes size={14} /> Browse Catalog ({products.length})
|
<Boxes size={14} /> Browse Catalogue ({products.length})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('inventory')}
|
onClick={() => setView('inventory')}
|
||||||
@@ -217,170 +177,222 @@ export default function StoreCatalogView({ locationid, storeName = 'your store'
|
|||||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={view === 'catalog' ? 'Search catalog products…' : 'Search your stock…'}
|
placeholder={view === 'catalogue' ? 'Search catalogue products…' : 'Search your stock…'}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="w-full pl-9 pr-9 py-2.5 border border-[#e2e8f0] rounded-xl text-xs outline-none bg-[#f8fafc] focus:bg-white focus:ring-1 focus:ring-[#581c87] transition-all"
|
className="w-full pl-9 pr-9 py-2.5 border border-[#e2e8f0] rounded-xl text-xs outline-none bg-[#f8fafc] focus:bg-white focus:ring-1 focus:ring-[#581c87] transition-all"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600">
|
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600"><X size={13} /></button>
|
||||||
<X size={13} />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{view === 'catalogue' && categories.length > 0 && (
|
||||||
{view === 'catalog' && (
|
|
||||||
<div className="flex items-center gap-sm flex-wrap">
|
<div className="flex items-center gap-sm flex-wrap">
|
||||||
<span className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 uppercase tracking-widest">
|
<span className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 uppercase tracking-widest"><Layers size={13} className="text-[#581c87]" /> Filter</span>
|
||||||
<Layers size={13} className="text-[#581c87]" /> Filter
|
|
||||||
</span>
|
|
||||||
<select
|
<select
|
||||||
value={categoryid}
|
value={category}
|
||||||
onChange={(e) => { setCategoryid(Number(e.target.value)); setSubcategoryid(0); }}
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
className="border border-[#e2e8f0] rounded-lg px-3 py-2 text-xs font-semibold text-zinc-700 bg-[#f8fafc] outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
|
className="border border-[#e2e8f0] rounded-lg px-3 py-2 text-xs font-semibold text-zinc-700 bg-[#f8fafc] outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value={0}>All categories</option>
|
<option value="ALL">All categories</option>
|
||||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
{categories.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
</select>
|
</select>
|
||||||
{categoryid > 0 && subcategories.length > 0 && (
|
|
||||||
<select
|
|
||||||
value={subcategoryid}
|
|
||||||
onChange={(e) => setSubcategoryid(Number(e.target.value))}
|
|
||||||
className="border border-[#e2e8f0] rounded-lg px-3 py-2 text-xs font-semibold text-zinc-700 bg-[#f8fafc] outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
|
|
||||||
>
|
|
||||||
<option value={0}>All subcategories</option>
|
|
||||||
{subcategories.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="md:ml-auto text-[11px] font-semibold text-zinc-400">
|
<div className="md:ml-auto text-[11px] font-semibold text-zinc-400">
|
||||||
{view === 'catalog' ? `${filtered.length} products` : `${inventory.length} stocked`}
|
{view === 'catalogue' ? `${filtered.length} products` : `${inventory.length} stocked`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Browse Catalog ── */}
|
{/* ── Browse Catalogue ── */}
|
||||||
{view === 'catalog' && (
|
{view === 'catalogue' && (
|
||||||
catalogQ.isLoading ? (
|
products.length === 0 ? (
|
||||||
<CenterState icon={<PackageSearch size={26} />} title="Loading catalog…" />
|
<CenterState
|
||||||
) : catalogQ.isError ? (
|
icon={<PackageSearch size={34} />}
|
||||||
<CenterState icon={<AlertTriangle size={26} />} title="Couldn't load the catalog" sub="Check your connection and try again." tone="error" />
|
title="No products published yet"
|
||||||
|
sub="Your admin hasn't added any products to the catalogue. Once they do, they'll appear here automatically for you to select."
|
||||||
|
/>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<CenterState icon={<Boxes size={26} />} title="No products found" sub="Your manager hasn't approved products matching this filter yet." />
|
<CenterState
|
||||||
|
icon={<Boxes size={34} />}
|
||||||
|
title="No products match your search"
|
||||||
|
sub="Try a different keyword or clear the filters to see the full catalogue."
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearch(''); setCategory('ALL'); }}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#581c87] hover:bg-purple-800 transition shadow-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<X size={13} /> Clear filters
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-gutter">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
|
||||||
{filtered.map((p) => {
|
{filtered.map((p) => {
|
||||||
const stocked = inStore.has(p.id);
|
const stocked = inStore.has(p.id);
|
||||||
const isSelected = selected.has(p.id);
|
const picked = picks[p.id] != null;
|
||||||
return (
|
return (
|
||||||
<div key={p.id} className="group bg-white border border-[#e2e8f0] rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-all flex flex-col">
|
<div key={p.id} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl p-md flex flex-col justify-between gap-sm shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-purple-200 hover:-translate-y-0.5 transition-all duration-300 relative group">
|
||||||
<div className="relative h-28 w-full overflow-hidden bg-zinc-50">
|
<div className="flex gap-md">
|
||||||
<img src={p.image} alt={p.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
{/* Thumbnail with hover zoom */}
|
||||||
|
<div className="w-16 h-16 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50 relative">
|
||||||
|
<img src={p.image} alt={p.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
|
||||||
{stocked && (
|
{stocked && (
|
||||||
<span className="absolute top-2 right-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500 text-white text-[9px] font-bold uppercase tracking-wide shadow">
|
<span className="absolute top-1 right-1 inline-flex items-center justify-center w-4 h-4 rounded-full bg-emerald-500 text-white shadow" title="In your store"><CheckCircle2 size={10} /></span>
|
||||||
<CheckCircle2 size={10} /> In Store
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 flex-1 flex flex-col">
|
<div className="flex-1 space-y-1 min-w-0">
|
||||||
<span className="inline-flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-[#581c87] mb-1">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<Tag size={9} /> {p.category}
|
<div className="min-w-0">
|
||||||
|
<h4 className="font-bold text-[#0f172a] leading-tight text-xs truncate group-hover:text-[#581c87] transition-colors">{p.name}</h4>
|
||||||
|
<span className="text-[10px] text-zinc-400 font-bold font-mono tracking-tight">{p.sku}</span>
|
||||||
|
</div>
|
||||||
|
{/* Category pill badge */}
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shrink-0 ${catBadgeClass(p.category)}`}>
|
||||||
|
{p.category.split(' / ')[0]}
|
||||||
</span>
|
</span>
|
||||||
<p className="font-bold text-xs text-[#0f172a] leading-snug line-clamp-2 min-h-[2rem]">{p.name}</p>
|
|
||||||
<div className="flex items-center justify-between mt-1.5 mb-3">
|
|
||||||
<span className="font-mono font-extrabold text-sm text-zinc-800">{p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}</span>
|
|
||||||
<span className="text-[9px] text-zinc-400 font-semibold">{p.unit}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stocked ? (
|
<div className="flex justify-between items-center pt-2">
|
||||||
<button disabled className="mt-auto w-full py-2 rounded-xl text-[11px] font-bold bg-emerald-50 text-emerald-600 border border-emerald-100 cursor-default flex items-center justify-center gap-1.5">
|
<div>
|
||||||
<CheckCircle2 size={13} /> Stocked
|
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Price</span>
|
||||||
</button>
|
<span className="font-extrabold text-zinc-700 font-mono text-xs">{p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}</span>
|
||||||
) : isSelected ? (
|
</div>
|
||||||
<button onClick={() => toggle(p.id)} className="mt-auto w-full py-2 rounded-xl text-[11px] font-bold bg-[#581c87] text-white hover:bg-purple-800 transition flex items-center justify-center gap-1.5 cursor-pointer">
|
<div className="text-right">
|
||||||
<Check size={13} /> Selected
|
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Admin Stock</span>
|
||||||
</button>
|
<span className="font-black text-emerald-600 font-mono text-xs">{p.adminQty}{p.unit ? ` ${p.unit}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stocked-status row (mirrors the admin card's status line) */}
|
||||||
|
<div className="flex justify-between items-center pt-2.5 border-t border-[#f1f5f9] mt-1 select-none">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 text-[10px] font-bold tracking-tight ${stocked ? 'text-emerald-600' : 'text-zinc-400'}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${stocked ? 'bg-emerald-500 animate-pulse' : 'bg-zinc-300'}`} />
|
||||||
|
{stocked ? 'In Your Store' : 'Not stocked yet'}
|
||||||
|
</span>
|
||||||
|
{p.unit && <span className="text-[9px] text-zinc-400 font-semibold">{p.unit}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pick action: quantity stepper when selected, else add button */}
|
||||||
|
{picked ? (
|
||||||
|
<div className="flex items-center justify-between gap-2 pt-2.5 border-t border-[#f1f5f9] mt-1">
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#581c87]"><Check size={12} /> Selected</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => setPickQty(p.id, picks[p.id] - 1)} className="w-6 h-6 rounded-lg border border-[#e2e8f0] text-zinc-500 hover:bg-zinc-50 font-bold cursor-pointer leading-none flex items-center justify-center"><Minus size={12} /></button>
|
||||||
|
<span className="w-8 text-center font-mono font-bold text-xs text-[#0f172a]">{picks[p.id]}</span>
|
||||||
|
<button onClick={() => setPickQty(p.id, picks[p.id] + 1)} className="w-6 h-6 rounded-lg border border-[#e2e8f0] text-zinc-500 hover:bg-zinc-50 font-bold cursor-pointer leading-none flex items-center justify-center"><Plus size={12} /></button>
|
||||||
|
<button onClick={() => togglePick(p.id)} title="Remove" className="ml-1 w-6 h-6 rounded-lg text-rose-500 hover:bg-rose-50 flex items-center justify-center cursor-pointer"><X size={13} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => toggle(p.id)} className="mt-auto w-full py-2 rounded-xl text-[11px] font-bold bg-white text-[#581c87] border border-purple-200 hover:bg-purple-50 transition flex items-center justify-center gap-1.5 cursor-pointer">
|
<button
|
||||||
|
onClick={() => togglePick(p.id)}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 pt-2.5 mt-1 border-t border-[#f1f5f9] text-[11px] font-bold text-[#581c87] hover:text-purple-800 cursor-pointer"
|
||||||
|
>
|
||||||
<Plus size={13} /> Add to Store
|
<Plus size={13} /> Add to Store
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── My Store Inventory ── */}
|
{/* ── My Store Inventory ── (card grid — same design as Browse Catalogue) */}
|
||||||
{view === 'inventory' && (
|
{view === 'inventory' && (
|
||||||
<div className="bg-white border border-[#e2e8f0] rounded-2xl shadow-sm overflow-hidden">
|
stockQ.isLoading ? (
|
||||||
<div className="overflow-x-auto">
|
<CenterState icon={<Store size={34} />} title="Loading your stock…" sub="Fetching the latest stock levels for your store." />
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-[#f8fafc] border-b border-[#e2e8f0] text-[10px] uppercase tracking-wider text-zinc-400 font-bold">
|
|
||||||
<th className="px-4 py-3 text-left">#</th>
|
|
||||||
<th className="px-4 py-3 text-left">Product</th>
|
|
||||||
<th className="px-4 py-3 text-left">Category</th>
|
|
||||||
<th className="px-4 py-3 text-right">In Stock</th>
|
|
||||||
<th className="px-4 py-3 text-center">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-[#f1f5f9]">
|
|
||||||
{stockQ.isLoading ? (
|
|
||||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-zinc-400">Loading your stock…</td></tr>
|
|
||||||
) : !locationid ? (
|
) : !locationid ? (
|
||||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-zinc-400">No store linked to your account yet.</td></tr>
|
<CenterState icon={<Store size={34} />} title="No store linked yet" sub="Your account isn't linked to a store outlet, so there's no inventory to show." />
|
||||||
) : inventory.length === 0 ? (
|
) : inventory.length === 0 ? (
|
||||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-zinc-400">No products stocked yet — add some from the catalog.</td></tr>
|
<CenterState icon={<PackageSearch size={34} />} title="No products stocked yet" sub="Add products from the catalogue and they'll appear here with live stock levels." />
|
||||||
|
) : filteredInventory.length === 0 ? (
|
||||||
|
<CenterState
|
||||||
|
icon={<Boxes size={34} />}
|
||||||
|
title="No stock matches your search"
|
||||||
|
sub="Try a different keyword to find an item in your store."
|
||||||
|
action={
|
||||||
|
<button onClick={() => setSearch('')} className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#581c87] hover:bg-purple-800 transition shadow-sm cursor-pointer">
|
||||||
|
<X size={13} /> Clear search
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
inventory.map((it, i) => (
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
|
||||||
<tr key={it.id || i} className="hover:bg-zinc-50/70 transition-colors">
|
{filteredInventory.map((it, i) => (
|
||||||
<td className="px-4 py-3 font-mono text-zinc-400">{i + 1}</td>
|
<div key={it.id || i} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl p-md flex flex-col justify-between gap-sm shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-purple-200 hover:-translate-y-0.5 transition-all duration-300 relative group">
|
||||||
<td className="px-4 py-3 font-bold text-[#0f172a]">{it.name}</td>
|
<div className="flex gap-md">
|
||||||
<td className="px-4 py-3 text-zinc-500">{it.category}</td>
|
{/* Thumbnail with status corner dot */}
|
||||||
<td className="px-4 py-3 text-right font-mono font-bold text-zinc-700">{it.closing.toLocaleString('en-IN')}</td>
|
<div className="w-16 h-16 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50 relative">
|
||||||
<td className="px-4 py-3 text-center">
|
<img src={it.image} alt={it.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border" style={{ background: `${it.color}14`, color: it.color, borderColor: `${it.color}40` }}>
|
<span className="absolute top-1 right-1 w-3 h-3 rounded-full border-2 border-white shadow" style={{ background: it.color }} title={it.label} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h4 className="font-bold text-[#0f172a] leading-tight text-xs truncate group-hover:text-[#581c87] transition-colors">{it.name}</h4>
|
||||||
|
<span className="text-[10px] text-zinc-400 font-bold font-mono tracking-tight">{it.sku}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shrink-0 ${catBadgeClass(it.category)}`}>
|
||||||
|
{it.category.split(' / ')[0]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">In Stock</span>
|
||||||
|
<span className="font-black font-mono text-xs" style={{ color: it.color }}>{it.closing.toLocaleString('en-IN')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Status</span>
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border" style={{ background: `${it.color}14`, color: it.color, borderColor: `${it.color}40` }}>{it.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status line (mirrors the catalogue card's footer) */}
|
||||||
|
<div className="flex justify-between items-center pt-2.5 border-t border-[#f1f5f9] mt-1 select-none">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold tracking-tight" style={{ color: it.color }}>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: it.color }} />
|
||||||
{it.label}
|
{it.label}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
<span className="text-[9px] text-zinc-400 font-semibold">{it.category.split(' / ')[0]}</span>
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Selection action bar (sticky) ── */}
|
{/* ── Selection action bar ── */}
|
||||||
{view === 'catalog' && selected.size > 0 && (
|
{view === 'catalogue' && pickCount > 0 && (
|
||||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[120] w-[min(640px,calc(100vw-2rem))]">
|
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[120] w-[min(680px,calc(100vw-2rem))]">
|
||||||
<div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-4 py-3">
|
<div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-4 py-3">
|
||||||
{notice ? (
|
{notice ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-bold">{selected.size} product{selected.size > 1 ? 's' : ''} marked for {storeName}</span>
|
<span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested for {storeName}</span>
|
||||||
<button onClick={() => { setSelected(new Set()); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer">Clear</button>
|
<button onClick={() => { setPicks({}); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<AwaitingApi label="Adding products to your store" api="stock-entry API" compact className="bg-white/5 border-white/15 text-purple-100" />
|
<AwaitingApi label="Submitting your store request" api="stock-request API" compact className="bg-white/5 border-white/15 text-purple-100" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center shrink-0"><Boxes size={15} /></span>
|
<span className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center shrink-0"><Boxes size={15} /></span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-bold truncate">{selected.size} product{selected.size > 1 ? 's' : ''} selected</p>
|
<p className="text-xs font-bold truncate">{pickCount} product{pickCount > 1 ? 's' : ''} · {Object.values(picks).reduce((a: number, b: number) => a + b, 0)} units</p>
|
||||||
<p className="text-[10px] text-purple-200">Ready to stock at {storeName}</p>
|
<p className="text-[10px] text-purple-200">Selected for {storeName}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button onClick={() => setSelected(new Set())} className="px-3 py-2 rounded-xl text-[11px] font-bold text-purple-200 hover:text-white hover:bg-white/10 transition cursor-pointer">Clear</button>
|
<button onClick={() => setPicks({})} className="px-3 py-2 rounded-xl text-[11px] font-bold text-purple-200 hover:text-white hover:bg-white/10 transition cursor-pointer">Clear</button>
|
||||||
<button onClick={commitSelectionToStore} className="px-4 py-2 rounded-xl text-[11px] font-bold bg-white text-[#581c87] hover:bg-purple-50 transition cursor-pointer flex items-center gap-1.5">
|
<button onClick={commitSelectionToStore} className="px-4 py-2 rounded-xl text-[11px] font-bold bg-white text-[#581c87] hover:bg-purple-50 transition cursor-pointer flex items-center gap-1.5">
|
||||||
<Plus size={13} /> Add to Store
|
<Check size={13} /> Request for Store
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,12 +404,45 @@ export default function StoreCatalogView({ locationid, storeName = 'your store'
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CenterState({ icon, title, sub, tone }: { icon: React.ReactNode; title: string; sub?: string; tone?: 'error' }) {
|
function CenterState({ icon, title, sub, action }: { icon: React.ReactNode; title: string; sub?: string; action?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-dashed border-[#e2e8f0] rounded-2xl p-12 text-center">
|
<div className="relative overflow-hidden bg-gradient-to-b from-white to-[#faf9ff] border border-[#eceef2] rounded-3xl px-6 py-16 sm:py-20 text-center shadow-sm">
|
||||||
<div className={`mx-auto mb-3 flex items-center justify-center w-14 h-14 rounded-2xl ${tone === 'error' ? 'bg-rose-50 text-rose-500' : 'bg-zinc-100 text-zinc-400'}`}>{icon}</div>
|
{/* Soft decorative glows */}
|
||||||
<p className="font-bold text-sm text-zinc-700">{title}</p>
|
<div className="pointer-events-none absolute -top-20 -right-20 w-60 h-60 rounded-full bg-purple-200/30 blur-3xl" />
|
||||||
{sub && <p className="text-xs text-zinc-400 mt-1">{sub}</p>}
|
<div className="pointer-events-none absolute -bottom-24 -left-20 w-60 h-60 rounded-full bg-indigo-200/30 blur-3xl" />
|
||||||
|
|
||||||
|
<div className="relative flex flex-col items-center">
|
||||||
|
{/* Icon with halo */}
|
||||||
|
<div className="relative mb-5">
|
||||||
|
<span className="absolute inset-0 -m-3 rounded-full bg-purple-300/25 blur-xl" />
|
||||||
|
<span className="relative flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-[#581c87] to-indigo-500 text-white shadow-lg shadow-purple-500/20 ring-8 ring-white">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-bold text-lg text-[#0f172a] tracking-tight">{title}</h3>
|
||||||
|
{sub && <p className="text-sm text-zinc-500 mt-2 max-w-md leading-relaxed">{sub}</p>}
|
||||||
|
|
||||||
|
{action && <div className="mt-6">{action}</div>}
|
||||||
|
|
||||||
|
{/* Ghost preview cards — hint at what will appear here */}
|
||||||
|
<div className="mt-9 flex items-end justify-center gap-3 sm:gap-4 opacity-70 select-none" aria-hidden>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`w-24 sm:w-28 rounded-2xl border border-[#eceef2] bg-white/70 p-3 shadow-sm ${i === 1 ? 'scale-110' : 'opacity-80'}`}
|
||||||
|
>
|
||||||
|
<div className="w-full h-10 rounded-lg bg-gradient-to-br from-zinc-100 to-zinc-200/70 mb-2" />
|
||||||
|
<div className="h-2 w-3/4 rounded-full bg-zinc-200 mb-1.5" />
|
||||||
|
<div className="h-2 w-1/2 rounded-full bg-zinc-100" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-400">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" /> Syncs automatically
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,19 +30,24 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
History,
|
History,
|
||||||
Building,
|
Building,
|
||||||
Award
|
Award,
|
||||||
|
ShoppingBag,
|
||||||
|
QrCode,
|
||||||
|
ChevronRight,
|
||||||
|
AtSign
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useFiestaStockStatement,
|
useFiestaStockStatement,
|
||||||
useFiestaTenantCustomers,
|
useFiestaTenantCustomers,
|
||||||
useFiestaCustomerOrders,
|
useFiestaCustomerOrders,
|
||||||
useFiestaMasterCatalog,
|
|
||||||
useFiestaRiders,
|
useFiestaRiders,
|
||||||
FIESTA_TENANT_ID
|
FIESTA_TENANT_ID
|
||||||
} from '../services/fiestaQueries';
|
} from '../services/fiestaQueries';
|
||||||
import { str as fstr, num as fnum } from '../services/fiestaApi';
|
import { str as fstr, num as fnum } from '../services/fiestaApi';
|
||||||
import { mapOrderStatus, shortTime } from '../services/fiestaMappers';
|
import { mapOrderStatus, shortTime } from '../services/fiestaMappers';
|
||||||
import ragulStoreCover from '../assets/images/store_front_view_1780299351800.png';
|
import ragulStoreCover from '../assets/images/store_front_view_1780299351800.png';
|
||||||
|
import OrdersDeliveriesView from './OrdersDeliveriesView';
|
||||||
|
import StoreQRView from './StoreQRView';
|
||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
|
|
||||||
interface StoreDetailViewProps {
|
interface StoreDetailViewProps {
|
||||||
@@ -68,6 +73,8 @@ interface StoreDetailViewProps {
|
|||||||
* Overview, Inventory & Catalogue, and Customers into separate pages. When
|
* Overview, Inventory & Catalogue, and Customers into separate pages. When
|
||||||
* omitted, the full tabbed console renders (admin store detail). */
|
* omitted, the full tabbed console renders (admin store detail). */
|
||||||
only?: 'overview' | 'inventory' | 'customers';
|
only?: 'overview' | 'inventory' | 'customers';
|
||||||
|
/** Merchant tenant to scope to; defaults to the shared constant. */
|
||||||
|
tenantId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback cover images
|
// Fallback cover images
|
||||||
@@ -86,8 +93,8 @@ const DETAIL_STORE_COVERS = [
|
|||||||
'https://images.unsplash.com/photo-1579621970563-ebec7560ff3e?auto=format&fit=crop&w=800&q=80'
|
'https://images.unsplash.com/photo-1579621970563-ebec7560ff3e?auto=format&fit=crop&w=800&q=80'
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function StoreDetailView({ store, onBack, canManage = true, only }: StoreDetailViewProps) {
|
export default function StoreDetailView({ store, onBack, canManage = true, only, tenantId = FIESTA_TENANT_ID }: StoreDetailViewProps) {
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers' | 'orders' | 'qr'>('overview');
|
||||||
// Which section to show: forced by `only` (separate-page mode) or the active tab.
|
// Which section to show: forced by `only` (separate-page mode) or the active tab.
|
||||||
const section = only ?? activeTab;
|
const section = only ?? activeTab;
|
||||||
// The immersive store banner shows on Overview (and the admin tabbed console);
|
// The immersive store banner shows on Overview (and the admin tabbed console);
|
||||||
@@ -133,8 +140,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
|
|||||||
const [localInventory, setLocalInventory] = useState<any[]>([]);
|
const [localInventory, setLocalInventory] = useState<any[]>([]);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [importState, setImportState] = useState<'idle' | 'reading' | 'parsing' | 'saving' | 'done'>('idle');
|
const [importState, setImportState] = useState<'idle' | 'reading' | 'parsing' | 'saving' | 'done'>('idle');
|
||||||
const [showGlobalModal, setShowGlobalModal] = useState(false);
|
|
||||||
const [selectedGlobalSkus, setSelectedGlobalSkus] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// ── Customer CRM Profile Drawer state ──────────────────────────────────────
|
// ── Customer CRM Profile Drawer state ──────────────────────────────────────
|
||||||
const [selectedCustomer, setSelectedCustomer] = useState<any | null>(null);
|
const [selectedCustomer, setSelectedCustomer] = useState<any | null>(null);
|
||||||
@@ -142,23 +147,17 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
|
|||||||
// ── API Queries with live locationid ───────────────────────────────────────
|
// ── API Queries with live locationid ───────────────────────────────────────
|
||||||
const locationid = store.locationid || 1097;
|
const locationid = store.locationid || 1097;
|
||||||
const stockQ = useFiestaStockStatement({
|
const stockQ = useFiestaStockStatement({
|
||||||
tenantid: FIESTA_TENANT_ID,
|
tenantid: tenantId,
|
||||||
locationid,
|
locationid,
|
||||||
pagesize: 100
|
pagesize: 100
|
||||||
});
|
});
|
||||||
const customersQ = useFiestaTenantCustomers({
|
const customersQ = useFiestaTenantCustomers({
|
||||||
tenantid: FIESTA_TENANT_ID,
|
tenantid: tenantId,
|
||||||
locationid,
|
locationid,
|
||||||
pagesize: 100
|
pagesize: 100
|
||||||
});
|
});
|
||||||
// Live active rider fleet for this tenant (powers KPI fleet count + fleet list)
|
// Live active rider fleet for this tenant (powers KPI fleet count + fleet list)
|
||||||
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
|
const ridersQ = useFiestaRiders({ tenantid: tenantId });
|
||||||
// Master catalogue rows for the Global Catalogue modal
|
|
||||||
const masterCatalogQ = useFiestaMasterCatalog({
|
|
||||||
tenantid: FIESTA_TENANT_ID,
|
|
||||||
locationid,
|
|
||||||
pagesize: 100
|
|
||||||
});
|
|
||||||
// Past orders for the currently-open CRM drawer customer (disabled when no id)
|
// Past orders for the currently-open CRM drawer customer (disabled when no id)
|
||||||
const customerOrdersQ = useFiestaCustomerOrders({
|
const customerOrdersQ = useFiestaCustomerOrders({
|
||||||
customerid: selectedCustomer?.id ?? null,
|
customerid: selectedCustomer?.id ?? null,
|
||||||
@@ -268,20 +267,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Global Master Catalogue (live) for the "Add from Catalogue" modal ──────
|
|
||||||
const globalCatalogueItems = (masterCatalogQ.data ?? []).map((row: any) => {
|
|
||||||
const price = fnum(row.retailprice) || fnum(row.price) || fnum(row.productcost);
|
|
||||||
return {
|
|
||||||
sku: fstr(row.sku) || fstr(row.productsku) || `SKU-${fstr(row.productid)}` || 'SKU-UNKNOWN',
|
|
||||||
name: fstr(row.productname) || fstr(row.name) || 'Product Item',
|
|
||||||
category: fstr(row.subcategoryname) || fstr(row.categoryname) || 'Catalogue',
|
|
||||||
price: price > 0 ? price : null,
|
|
||||||
image:
|
|
||||||
fstr(row.productimage) ||
|
|
||||||
'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=150&q=80'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Actions simulation handles
|
// Actions simulation handles
|
||||||
const handleReplenishSubmit = (e: React.FormEvent) => {
|
const handleReplenishSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -328,30 +313,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
|
|||||||
}, 700);
|
}, 700);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add items from Global Catalog
|
|
||||||
const handleAddGlobalCatalogue = () => {
|
|
||||||
if (selectedGlobalSkus.length === 0) {
|
|
||||||
showToast('Kindly select at least one catalogue item.', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemsToAdd = globalCatalogueItems.filter(item => selectedGlobalSkus.includes(item.sku)).map(item => ({
|
|
||||||
...item,
|
|
||||||
stockLevel: 0,
|
|
||||||
maxCapacity: 200,
|
|
||||||
status: 'Critical'
|
|
||||||
}));
|
|
||||||
|
|
||||||
setLocalInventory(prev => {
|
|
||||||
const filtered = prev.filter(item => !itemsToAdd.some(ni => ni.sku === item.sku));
|
|
||||||
return [...filtered, ...itemsToAdd];
|
|
||||||
});
|
|
||||||
|
|
||||||
showToast(`${itemsToAdd.length} products synced from Master Global Catalogue successfully!`, 'success');
|
|
||||||
setSelectedGlobalSkus([]);
|
|
||||||
setShowGlobalModal(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportLedger = () => {
|
const handleExportLedger = () => {
|
||||||
showToast(`Generating secure PDF ledger audit reports for ${store.name}...`, 'info');
|
showToast(`Generating secure PDF ledger audit reports for ${store.name}...`, 'info');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -521,7 +482,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Layers size={14} />
|
<Layers size={14} />
|
||||||
<span>Inventory & Catalogue ({inventoryList.length})</span>
|
<span>Inventory ({inventoryList.length})</span>
|
||||||
{inventoryList.some(item => item.status === 'Critical') && (
|
{inventoryList.some(item => item.status === 'Critical') && (
|
||||||
<span className="px-1.5 py-0.5 rounded-full bg-rose-500 text-white text-[8px] font-black leading-none animate-pulse">!</span>
|
<span className="px-1.5 py-0.5 rounded-full bg-rose-500 text-white text-[8px] font-black leading-none animate-pulse">!</span>
|
||||||
)}
|
)}
|
||||||
@@ -537,6 +498,28 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
|
|||||||
<Users size={14} />
|
<Users size={14} />
|
||||||
<span>Customer CRM Base ({customersList.length})</span>
|
<span>Customer CRM Base ({customersList.length})</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('orders')}
|
||||||
|
className={`flex items-center gap-xs px-md pb-sm text-xs font-bold uppercase tracking-wider cursor-pointer border-b-2 transition-all ${
|
||||||
|
activeTab === 'orders'
|
||||||
|
? 'border-b-[#581c87] text-[#581c87]'
|
||||||
|
: 'border-b-transparent text-zinc-400 hover:text-zinc-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShoppingBag size={14} />
|
||||||
|
<span>Orders & Deliveries</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('qr')}
|
||||||
|
className={`flex items-center gap-xs px-md pb-sm text-xs font-bold uppercase tracking-wider cursor-pointer border-b-2 transition-all ${
|
||||||
|
activeTab === 'qr'
|
||||||
|
? 'border-b-[#581c87] text-[#581c87]'
|
||||||
|
: 'border-b-transparent text-zinc-400 hover:text-zinc-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<QrCode size={14} />
|
||||||
|
<span>Store QR</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -730,7 +713,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
|
|||||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-450" />
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-450" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search local stocks catalogue..."
|
placeholder="Search inventory by product or SKU..."
|
||||||
value={stockSearch}
|
value={stockSearch}
|
||||||
onChange={(e) => setStockSearch(e.target.value)}
|
onChange={(e) => setStockSearch(e.target.value)}
|
||||||
className="w-full pl-9 pr-4 py-2.5 border border-[#e2e8f0] rounded-xl text-xs outline-none bg-[#f8fafc] focus:bg-white focus:ring-1 focus:ring-[#581c87] transition-all"
|
className="w-full pl-9 pr-4 py-2.5 border border-[#e2e8f0] rounded-xl text-xs outline-none bg-[#f8fafc] focus:bg-white focus:ring-1 focus:ring-[#581c87] transition-all"
|
||||||
@@ -749,14 +732,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
|
|||||||
<span>Import Manual (CSV)</span>
|
<span>Import Manual (CSV)</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => { setSelectedGlobalSkus([]); setShowGlobalModal(true); }}
|
|
||||||
className="px-3 py-2 bg-[#0f172a] text-white hover:bg-zinc-800 rounded-xl font-bold flex items-center gap-xs cursor-pointer transition shadow-sm"
|
|
||||||
>
|
|
||||||
<Globe size={14} />
|
|
||||||
<span>Global Catalogue Master</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span className="h-6 w-[1px] bg-zinc-200 mx-xs hidden md:block" />
|
<span className="h-6 w-[1px] bg-zinc-200 mx-xs hidden md:block" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -774,7 +749,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
|
|||||||
<div className="bg-white border border-[#eceef2] rounded-2xl overflow-hidden shadow-sm">
|
<div className="bg-white border border-[#eceef2] rounded-2xl overflow-hidden shadow-sm">
|
||||||
<div className="p-md border-b border-[#eceef2] bg-[#f8fafc] flex justify-between items-center">
|
<div className="p-md border-b border-[#eceef2] bg-[#f8fafc] flex justify-between items-center">
|
||||||
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
|
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
|
||||||
Product Stock Levels & Catalog
|
Product Stock Levels
|
||||||
</h4>
|
</h4>
|
||||||
<span className="text-[10px] font-bold text-[#581c87] bg-purple-50 px-2 py-0.5 rounded border border-purple-100 uppercase tracking-wide">Live list</span>
|
<span className="text-[10px] font-bold text-[#581c87] bg-purple-50 px-2 py-0.5 rounded border border-purple-100 uppercase tracking-wide">Live list</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -885,111 +860,175 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section === 'customers' && (
|
{section === 'customers' && (() => {
|
||||||
<div className="space-y-lg animate-in fade-in duration-300">
|
const withPhone = customersList.filter((c: any) => c.phone && c.phone !== '—').length;
|
||||||
|
const withEmail = customersList.filter((c: any) => c.email).length;
|
||||||
{/* Customer directory search and metrics */}
|
// Jewel-tone identity per customer (deterministic by name) — a calm header
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-md bg-white border border-[#eceef2] p-md rounded-2xl shadow-sm">
|
// band gradient + a soft solid avatar tint drawn from the same hue.
|
||||||
<div className="relative w-full sm:w-80 sm:shrink-0">
|
const tones = [
|
||||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
{ soft: '#f3effb', fg: '#6d28d9', band: 'linear-gradient(135deg,#6d28d9 0%,#9333ea 100%)' },
|
||||||
<input
|
{ soft: '#e9f5f1', fg: '#0f766e', band: 'linear-gradient(135deg,#0f766e 0%,#14b8a6 100%)' },
|
||||||
type="text"
|
{ soft: '#fdf0eb', fg: '#c2410c', band: 'linear-gradient(135deg,#c2410c 0%,#f97316 100%)' },
|
||||||
placeholder="Search CRM profile roster..."
|
{ soft: '#ebeefb', fg: '#3a4fc4', band: 'linear-gradient(135deg,#3949c4 0%,#6366f1 100%)' },
|
||||||
value={customerSearch}
|
{ soft: '#fceef4', fg: '#be185d', band: 'linear-gradient(135deg,#be185d 0%,#ec4899 100%)' },
|
||||||
onChange={(e) => setCustomerSearch(e.target.value)}
|
{ soft: '#e9f3fb', fg: '#0369a1', band: 'linear-gradient(135deg,#0369a1 0%,#0ea5e9 100%)' },
|
||||||
className="w-full pl-9 pr-4 py-2.5 border border-[#e2e8f0] rounded-xl text-xs outline-none bg-[#f8fafc] focus:bg-white focus:ring-1 focus:ring-[#581c87] transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0 w-full sm:w-auto sm:min-w-[18rem]">
|
|
||||||
<AwaitingApi label="Customer analytics" api="[R11]" compact />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Customer list directory */}
|
|
||||||
<div className="bg-white border border-[#eceef2] rounded-2xl overflow-hidden shadow-sm">
|
|
||||||
<div className="p-md border-b border-[#eceef2] bg-[#f8fafc] flex justify-between items-center">
|
|
||||||
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
|
|
||||||
Active Customer Directory
|
|
||||||
</h4>
|
|
||||||
<span className="text-[10px] font-bold text-[#581c87] bg-purple-50 px-2 py-0.5 rounded border border-purple-100 uppercase tracking-wide">Customer registry</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto text-xs font-sans">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-450 text-[10px] uppercase font-bold tracking-wider">
|
|
||||||
<tr>
|
|
||||||
<th className="px-md py-sm">Customer Profile</th>
|
|
||||||
<th className="px-md py-sm">Contact Details</th>
|
|
||||||
<th className="px-md py-sm">Delivery Address</th>
|
|
||||||
<th className="px-md py-sm">Total Dispatches</th>
|
|
||||||
<th className="px-md py-sm">Gross Volume Spent</th>
|
|
||||||
<th className="px-md py-sm text-right">Audit CRM Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-[#f1f5f9] font-medium text-zinc-700">
|
|
||||||
{customersList.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="text-center py-10 text-zinc-400 font-medium">
|
|
||||||
No customer accounts found matching search keyword.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
customersList.map((c, idx) => {
|
|
||||||
const initials = c.name.split(' ').map((n: string) => n[0]).join('');
|
|
||||||
const gradients = [
|
|
||||||
'from-purple-500 to-indigo-500 text-white',
|
|
||||||
'from-rose-500 to-pink-500 text-white',
|
|
||||||
'from-sky-500 to-indigo-500 text-white',
|
|
||||||
'from-emerald-500 to-teal-500 text-white',
|
|
||||||
'from-amber-500 to-orange-500 text-white'
|
|
||||||
];
|
];
|
||||||
const avatarGrad = gradients[idx % gradients.length];
|
const toneFor = (name: string) => {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||||
|
return tones[h % tones.length];
|
||||||
|
};
|
||||||
|
const initialsOf = (name: string) => (name || 'C').split(' ').filter(Boolean).map((n: string) => n[0]).slice(0, 2).join('').toUpperCase();
|
||||||
|
// A short, human locality from the messy delivery address (skip door numbers).
|
||||||
|
const localityOf = (addr: string) => {
|
||||||
|
const parts = (addr || '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
return parts.find((p) => /[a-zA-Z]/.test(p) && !/^\d/.test(p)) || parts[1] || parts[0] || '';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={idx} className="hover:bg-[#f8fafc]/60 transition-colors">
|
<div className="animate-in fade-in duration-300">
|
||||||
<td className="px-md py-md">
|
|
||||||
<div className="flex items-center gap-xs">
|
{/* Page heading */}
|
||||||
<div className={`w-8 h-8 rounded-full bg-gradient-to-br ${avatarGrad} flex items-center justify-center font-black text-[10px] shadow-sm shrink-0`}>
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
{initials}
|
<div>
|
||||||
|
<h2 className="text-[22px] font-semibold tracking-tight text-[#0f172a]">Customers</h2>
|
||||||
|
<p className="text-[13px] text-zinc-500 mt-1">
|
||||||
|
{customersList.length} {customersList.length === 1 ? 'person orders' : 'people order'} from{' '}
|
||||||
|
<span className="font-medium text-zinc-700">{store.name}</span>
|
||||||
|
<span className="text-zinc-300"> · </span>
|
||||||
|
{withPhone} with phone<span className="text-zinc-300"> · </span>{withEmail} with email
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full sm:w-64 shrink-0">
|
||||||
|
<Search size={15} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search customers"
|
||||||
|
value={customerSearch}
|
||||||
|
onChange={(e) => setCustomerSearch(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-3 py-2.5 border border-[#e6e8ee] rounded-full text-[13px] text-[#0f172a] placeholder:text-zinc-400 outline-none bg-white focus:border-[#581c87] focus:ring-4 focus:ring-[#581c87]/8 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile cards */}
|
||||||
|
{customersList.length === 0 ? (
|
||||||
|
<div className="bg-white border border-[#e8e9ee] rounded-3xl py-20 flex flex-col items-center gap-3 text-center px-6">
|
||||||
|
<span className="flex items-center justify-center w-14 h-14 rounded-full bg-[#f4f0fb] text-[#6d28d9]"><Users size={22} /></span>
|
||||||
|
<div>
|
||||||
|
<p className="text-[15px] font-semibold text-[#0f172a]">No customers yet</p>
|
||||||
|
<p className="text-[13px] text-zinc-500 mt-1 max-w-xs leading-relaxed">
|
||||||
|
{customerSearch ? 'Nothing matches your search.' : 'Customers will appear here once they place their first order.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{customerSearch && (
|
||||||
|
<button onClick={() => setCustomerSearch('')} className="mt-1 text-[13px] font-medium text-[#581c87] hover:underline cursor-pointer">Clear search</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white border border-[#e8e9ee] rounded-2xl shadow-[0_1px_3px_rgba(16,24,40,0.04)] overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse" style={{ minWidth: 860 }}>
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#fafbfc] border-b border-[#eceef2] text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
|
||||||
|
<th className="px-5 py-3.5 font-semibold">Customer</th>
|
||||||
|
<th className="px-5 py-3.5 font-semibold">Phone</th>
|
||||||
|
<th className="px-5 py-3.5 font-semibold">Email</th>
|
||||||
|
<th className="px-5 py-3.5 font-semibold">Delivery address</th>
|
||||||
|
<th className="px-5 py-3.5 font-semibold text-right">Profile</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#f1f2f5]">
|
||||||
|
{customersList.map((c: any, idx: number) => {
|
||||||
|
const tone = toneFor(c.name || `c${idx}`);
|
||||||
|
const locality = localityOf(c.address);
|
||||||
|
return (
|
||||||
|
<tr key={c.id ?? idx} className="group hover:bg-[#fbfaff] transition-colors">
|
||||||
|
{/* Customer */}
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className="w-9 h-9 rounded-xl flex items-center justify-center font-bold text-[12px] shrink-0 ring-1 ring-black/[0.04]"
|
||||||
|
style={{ background: tone.soft, color: tone.fg }}
|
||||||
|
>
|
||||||
|
{initialsOf(c.name)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold text-[14px] text-[#0f172a] truncate leading-tight" title={c.name}>{c.name}</p>
|
||||||
|
{locality && (
|
||||||
|
<p className="text-[12px] text-zinc-400 mt-0.5 inline-flex items-center gap-1 truncate max-w-[180px]">
|
||||||
|
<MapPin size={11} className="shrink-0" /> {locality}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-[#0f172a]">{c.name}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-md py-md font-mono text-zinc-500 font-semibold">{c.phone}</td>
|
{/* Phone */}
|
||||||
<td className="px-md py-md max-w-xs truncate text-zinc-500 font-medium" title={c.address}>
|
<td className="px-5 py-3.5">
|
||||||
{c.address}
|
<span className="text-[13px] text-zinc-700 tabular-nums">{c.phone}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-md py-md text-zinc-700 font-bold">{c.ordersCount} orders</td>
|
{/* Email */}
|
||||||
<td className="px-md py-md text-[#581c87] font-black">{c.totalSpent}</td>
|
<td className="px-5 py-3.5">
|
||||||
<td className="px-md py-md text-right space-x-sm shrink-0">
|
{c.email
|
||||||
|
? <span className="text-[13px] text-zinc-600 truncate inline-block max-w-[220px] align-middle" title={c.email}>{c.email}</span>
|
||||||
|
: <span className="text-[13px] text-zinc-300">—</span>}
|
||||||
|
</td>
|
||||||
|
{/* Address */}
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
<span className="text-[13px] text-zinc-500 truncate inline-block max-w-[280px] align-middle" title={c.address}>{c.address}</span>
|
||||||
|
</td>
|
||||||
|
{/* Action */}
|
||||||
|
<td className="px-5 py-3.5 text-right whitespace-nowrap">
|
||||||
|
<div className="inline-flex items-center justify-end gap-2">
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<button
|
<button
|
||||||
onClick={() => showToast(`Voucher promo code successfully dispatched to ${c.phone}.`, 'success')}
|
onClick={() => showToast(`Promo code sent to ${c.phone}.`, 'success')}
|
||||||
className="px-2.5 py-1 border border-zinc-200 hover:border-purple-300 rounded-lg font-bold text-[10px] text-zinc-650 hover:bg-purple-50/50 hover:text-[#581c87] cursor-pointer transition"
|
title="Send promo SMS"
|
||||||
|
className="w-8 h-8 inline-flex items-center justify-center rounded-lg border border-[#e6e8ee] text-zinc-500 hover:text-[#581c87] hover:border-[#d6bcf0] hover:bg-[#faf5ff] transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
Promo SMS
|
<Send size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedCustomer(c)}
|
onClick={() => setSelectedCustomer(c)}
|
||||||
className="px-2.5 py-1 bg-[#0f172a] hover:bg-zinc-800 text-white rounded-lg font-bold text-[10px] cursor-pointer transition shadow-sm"
|
className="inline-flex items-center gap-1.5 rounded-lg border border-[#e6e8ee] px-3 py-1.5 text-[12.5px] font-semibold text-zinc-700 hover:bg-[#581c87] hover:border-[#581c87] hover:text-white transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
View Profile
|
View <ChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
)}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="px-5 py-3 border-t border-[#eceef2] bg-[#fafbfc] text-[12px] text-zinc-400">
|
||||||
|
Showing {customersList.length} {customersList.length === 1 ? 'customer' : 'customers'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Orders & Deliveries moved out of the store console into their own pages. */}
|
{/* Orders & Deliveries — admin full console only (user store pages use the
|
||||||
|
dedicated Orders / Deliveries nav items instead). */}
|
||||||
|
{section === 'orders' && (
|
||||||
|
<OrdersDeliveriesView searchQuery="" isCoimbatoreView={false} locationid={locationid} tenantId={tenantId} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Store QR — scannable storefront link for this outlet. */}
|
||||||
|
{section === 'qr' && (
|
||||||
|
<div className="w-full">
|
||||||
|
<StoreQRView
|
||||||
|
tenantId={tenantId}
|
||||||
|
locationid={locationid}
|
||||||
|
storeName={store.name}
|
||||||
|
storeZone={store.zone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Replenishment Modal Dialog Overlay ── */}
|
{/* ── Replenishment Modal Dialog Overlay ── */}
|
||||||
{replenishModal.show && replenishModal.item && (
|
{replenishModal.show && replenishModal.item && (
|
||||||
@@ -1112,7 +1151,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-bold text-[#0f172a] uppercase tracking-wide">
|
<p className="font-bold text-[#0f172a] uppercase tracking-wide">
|
||||||
{importState === 'reading' && 'Reading uploaded CSV sheets...'}
|
{importState === 'reading' && 'Reading uploaded CSV sheets...'}
|
||||||
{importState === 'parsing' && 'Scanning item SKU catalog mapping...'}
|
{importState === 'parsing' && 'Scanning item SKU catalogue mapping...'}
|
||||||
{importState === 'saving' && 'Syncing manifest entries with local inventory...'}
|
{importState === 'saving' && 'Syncing manifest entries with local inventory...'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-zinc-400 font-semibold mt-1">Kindly keep this window open while processing dispatches.</p>
|
<p className="text-[10px] text-zinc-400 font-semibold mt-1">Kindly keep this window open while processing dispatches.</p>
|
||||||
@@ -1145,94 +1184,6 @@ export default function StoreDetailView({ store, onBack, canManage = true, only
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Choose from Global Catalogue Modal ── */}
|
|
||||||
{showGlobalModal && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md animate-in fade-in duration-200"
|
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) setShowGlobalModal(false); }}
|
|
||||||
>
|
|
||||||
<div className="bg-white border border-[#e2e8f0] rounded-2xl w-full max-w-[28rem] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-xs font-sans">
|
|
||||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0">
|
|
||||||
<h4 className="font-bold text-[#0f172a] flex items-center gap-xs">
|
|
||||||
<Globe size={15} className="text-[#581c87]" />
|
|
||||||
Select Products from Master Catalogue
|
|
||||||
</h4>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowGlobalModal(false)}
|
|
||||||
className="p-1 hover:bg-zinc-200 rounded-full text-zinc-400 cursor-pointer transition-colors"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-md space-y-md overflow-y-auto flex-1">
|
|
||||||
<p className="text-zinc-500 leading-relaxed font-medium">
|
|
||||||
Choose master items from the national database to stock and commission locally at <strong>{store.name}</strong>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{globalCatalogueItems.length === 0 ? (
|
|
||||||
<div className="py-8 text-center text-zinc-400 font-medium">
|
|
||||||
No catalogue products available yet.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-sm divide-y divide-[#f1f5f9]">
|
|
||||||
{globalCatalogueItems.map((item) => {
|
|
||||||
const isChecked = selectedGlobalSkus.includes(item.sku);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.sku}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedGlobalSkus(prev =>
|
|
||||||
isChecked ? prev.filter(s => s !== item.sku) : [...prev, item.sku]
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="py-2.5 flex items-center justify-between gap-sm cursor-pointer select-none hover:bg-zinc-50/50 rounded-lg px-1 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-sm min-w-0">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isChecked}
|
|
||||||
onChange={() => {}} // handled by row click
|
|
||||||
className="w-4 h-4 rounded text-[#581c87] border-[#e2e8f0] focus:ring-purple-500"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={item.image}
|
|
||||||
alt={item.name}
|
|
||||||
className="w-9 h-9 object-cover rounded-lg border border-zinc-200 shrink-0"
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-bold text-[#0f172a] truncate">{item.name}</p>
|
|
||||||
<p className="text-[9px] text-zinc-450 font-bold uppercase tracking-wider">{item.category} · SKU: {item.sku}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="font-bold text-zinc-800 shrink-0">{item.price != null ? `₹${item.price.toLocaleString('en-IN')}` : '—'}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-md border-t border-[#f1f5f9] flex justify-end gap-sm bg-[#f8fafc] shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowGlobalModal(false)}
|
|
||||||
className="px-4 py-2 border border-[#e2e8f0] rounded-xl font-bold text-zinc-500 hover:bg-zinc-50 cursor-pointer"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAddGlobalCatalogue}
|
|
||||||
className="px-4 py-2 bg-[#581c87] text-white rounded-xl font-bold hover:bg-purple-800 cursor-pointer shadow-sm"
|
|
||||||
>
|
|
||||||
Add Selected to Store
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Customer CRM Profile Side Drawer Overlay ── */}
|
{/* ── Customer CRM Profile Side Drawer Overlay ── */}
|
||||||
{selectedCustomer && (
|
{selectedCustomer && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
146
src/components/StoreQRView.tsx
Normal file
146
src/components/StoreQRView.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,10 +4,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
User,
|
|
||||||
Mail,
|
Mail,
|
||||||
Phone,
|
Phone,
|
||||||
Store,
|
Store,
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ClipboardList,
|
ClipboardList,
|
||||||
Layers,
|
Layers,
|
||||||
Users,
|
Users,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useFiestaTenantLocations,
|
useFiestaTenantLocations,
|
||||||
@@ -33,6 +34,7 @@ import OrdersView from './OrdersView';
|
|||||||
import DeliveriesView from './DeliveriesView';
|
import DeliveriesView from './DeliveriesView';
|
||||||
import DispatchView from './DispatchView';
|
import DispatchView from './DispatchView';
|
||||||
import DeliveryReportsView from './DeliveryReportsView';
|
import DeliveryReportsView from './DeliveryReportsView';
|
||||||
|
import StoreQRView from './StoreQRView';
|
||||||
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
||||||
|
|
||||||
interface UserStorePageProps {
|
interface UserStorePageProps {
|
||||||
@@ -46,13 +48,12 @@ interface UserStorePageProps {
|
|||||||
// gets a matching branch in `renderSection` below.
|
// gets a matching branch in `renderSection` below.
|
||||||
const NAV_ITEMS: UserNavItem[] = [
|
const NAV_ITEMS: UserNavItem[] = [
|
||||||
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
||||||
{ id: 'inventory', label: 'Inventory & Catalog', icon: Layers },
|
{ id: 'inventory', label: 'Product Catalogue', icon: Layers },
|
||||||
{ id: 'customers', label: 'Customers', icon: Users },
|
{ id: 'customers', label: 'Customers', icon: Users },
|
||||||
{ id: 'orders', label: 'Orders', icon: ShoppingBag },
|
{ id: 'orders', label: 'Orders', icon: ShoppingBag },
|
||||||
{ id: 'deliveries', label: 'Deliveries', icon: Truck },
|
{ id: 'deliveries', label: 'Deliveries', icon: Truck },
|
||||||
{ id: 'dispatch', label: 'Dispatch', icon: Route },
|
{ id: 'dispatch', label: 'Dispatch', icon: Route },
|
||||||
{ id: 'reports', label: 'Delivery Reports', icon: ClipboardList },
|
{ id: 'reports', label: 'Reports', icon: ClipboardList },
|
||||||
{ id: 'account', label: 'My Account', icon: User },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
type StoreShape = React.ComponentProps<typeof StoreDetailView>['store'];
|
type StoreShape = React.ComponentProps<typeof StoreDetailView>['store'];
|
||||||
@@ -66,9 +67,14 @@ type StoreShape = React.ComponentProps<typeof StoreDetailView>['store'];
|
|||||||
export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [activeSection, setActiveSection] = useState<string>('console');
|
const [activeSection, setActiveSection] = useState<string>('console');
|
||||||
|
const [showQrModal, setShowQrModal] = useState(false);
|
||||||
|
|
||||||
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
// Scope every query to the signed-in merchant's tenant; the shared constant is
|
||||||
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
// only a fallback for legacy sessions whose record predates tenantid capture.
|
||||||
|
const tenantId = user.tenantid || FIESTA_TENANT_ID;
|
||||||
|
|
||||||
|
const locationsQ = useFiestaTenantLocations(tenantId);
|
||||||
|
const locSummaryQ = useFiestaLocationSummary(tenantId);
|
||||||
|
|
||||||
const locations = locationsQ.data ?? [];
|
const locations = locationsQ.data ?? [];
|
||||||
const summaries = locSummaryQ.data ?? [];
|
const summaries = locSummaryQ.data ?? [];
|
||||||
@@ -188,14 +194,14 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
|
|
||||||
// Logistics console — scoped to this user's store. These views own their
|
// Logistics console — scoped to this user's store. These views own their
|
||||||
// loading/error states, so they don't need the store-console load gating below.
|
// loading/error states, so they don't need the store-console load gating below.
|
||||||
if (activeSection === 'orders') return <OrdersView locationid={resolvedLocationId || undefined} />;
|
if (activeSection === 'orders') return <OrdersView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
||||||
if (activeSection === 'deliveries') return <DeliveriesView locationid={resolvedLocationId || undefined} />;
|
if (activeSection === 'deliveries') return <DeliveriesView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
||||||
if (activeSection === 'dispatch') return <DispatchView locationid={resolvedLocationId || undefined} />;
|
if (activeSection === 'dispatch') return <DispatchView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
||||||
if (activeSection === 'reports') return <DeliveryReportsView />;
|
if (activeSection === 'reports') return <DeliveryReportsView tenantId={tenantId} />;
|
||||||
// Inventory & Catalog is its own page: the manager-curated catalog the user
|
// Inventory & Catalog is its own page: the manager-curated catalog the user
|
||||||
// stocks from (the catalog query is tenant-level, so it doesn't need the store
|
// stocks from (the catalog query is tenant-level, so it doesn't need the store
|
||||||
// gating below — only "My Store Inventory" uses the resolved location id).
|
// gating below — only "My Store Inventory" uses the resolved location id).
|
||||||
if (activeSection === 'inventory') return <StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} />;
|
if (activeSection === 'inventory') return <StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} tenantId={tenantId} />;
|
||||||
|
|
||||||
// The store console needs a resolved store, so gate it on the load state.
|
// The store console needs a resolved store, so gate it on the load state.
|
||||||
if (locationsQ.isLoading || locSummaryQ.isLoading) {
|
if (locationsQ.isLoading || locSummaryQ.isLoading) {
|
||||||
@@ -259,7 +265,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
// Overview & Performance; Customers is its own page (Inventory & Catalog is the
|
// Overview & Performance; Customers is its own page (Inventory & Catalog is the
|
||||||
// dedicated StoreCatalogView, handled above).
|
// dedicated StoreCatalogView, handled above).
|
||||||
const only = activeSection === 'customers' ? 'customers' : 'overview';
|
const only = activeSection === 'customers' ? 'customers' : 'overview';
|
||||||
return <StoreDetailView store={buildStore()} canManage={false} only={only} />;
|
return <StoreDetailView store={buildStore()} canManage={false} only={only} tenantId={tenantId} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -269,6 +275,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
onToggleSidebar={() => setSidebarOpen((s) => !s)}
|
onToggleSidebar={() => setSidebarOpen((s) => !s)}
|
||||||
onHelpClick={handleHelp}
|
onHelpClick={handleHelp}
|
||||||
onLogoutClick={onLogout}
|
onLogoutClick={onLogout}
|
||||||
|
onAccountClick={() => setActiveSection('account')}
|
||||||
|
onQrClick={() => setShowQrModal(true)}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -296,6 +304,40 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Store QR — centered modal opened from the navbar QR button. Portaled to
|
||||||
|
body so `fixed inset-0` is viewport-relative regardless of ancestors. */}
|
||||||
|
{showQrModal &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
|
||||||
|
style={{ background: 'rgba(15,23,42,0.45)', backdropFilter: 'blur(4px)' }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setShowQrModal(false); }}
|
||||||
|
>
|
||||||
|
<div className="relative w-full mx-auto" style={{ maxWidth: 600 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowQrModal(false)}
|
||||||
|
title="Close"
|
||||||
|
aria-label="Close"
|
||||||
|
className="absolute top-3 right-3 z-10 p-1.5 rounded-full bg-white/15 hover:bg-white/25 text-white ring-1 ring-white/25 backdrop-blur-sm cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
<StoreQRView
|
||||||
|
tenantId={tenantId}
|
||||||
|
locationid={resolvedLocationId || undefined}
|
||||||
|
storeName={storeName}
|
||||||
|
storeZone={
|
||||||
|
matchedLoc
|
||||||
|
? [fstr(matchedLoc.suburb), fstr(matchedLoc.city)].filter(Boolean).join(', ') || undefined
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
storeAddress={matchedLoc ? fstr(matchedLoc.address) || undefined : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ interface UserStoreSidebarProps {
|
|||||||
export default function UserStoreSidebar({ items, activeId, onSelect, isOpen }: UserStoreSidebarProps) {
|
export default function UserStoreSidebar({ items, activeId, onSelect, isOpen }: UserStoreSidebarProps) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={`fixed left-0 top-0 h-screen bg-[#581c87] border-r border-[#4c1d95] text-white flex-col py-xl pt-24 z-40 hidden md:flex transition-all duration-300 ${
|
className={`fixed left-0 top-0 h-screen bg-[#581c87] border-r border-[#4c1d95] text-white flex-col py-xl pt-20 z-40 hidden md:flex transition-all duration-300 ${
|
||||||
isOpen ? 'w-64' : 'w-20'
|
isOpen ? 'w-64' : 'w-20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* Self-contained: search box, role filter, live query, and Add User modal.
|
* Self-contained: search box, role filter, live query, and Add User modal.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
Search,
|
Search,
|
||||||
@@ -21,15 +21,17 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
Phone,
|
Phone,
|
||||||
MapPin,
|
MapPin,
|
||||||
Lock,
|
|
||||||
UserCheck,
|
UserCheck,
|
||||||
Check,
|
Check,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Coins
|
Coins,
|
||||||
|
Store,
|
||||||
|
Bike
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useFiestaUsers, useFiestaCreateUser } from '../services/fiestaQueries';
|
import { useFiestaUsers, useFiestaCreateUser, useFiestaRiderShifts, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||||
import { useAppRoles } from '../services/queries';
|
import { useAppRoles } from '../services/queries';
|
||||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||||
|
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
|
||||||
|
|
||||||
interface UsersPanelProps {
|
interface UsersPanelProps {
|
||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
@@ -45,7 +47,7 @@ const USER_AVATARS = [
|
|||||||
|
|
||||||
const ROLE_THEMES: Record<number, { bg: string; text: string; border: string; label: string }> = {
|
const ROLE_THEMES: Record<number, { bg: string; text: string; border: string; label: string }> = {
|
||||||
1: { bg: 'bg-rose-50/75', text: 'text-rose-700', border: 'border-rose-100', label: 'Owner' },
|
1: { bg: 'bg-rose-50/75', text: 'text-rose-700', border: 'border-rose-100', label: 'Owner' },
|
||||||
2: { bg: 'bg-amber-50/75', text: 'text-amber-700', border: 'border-amber-100', label: 'Manager' },
|
2: { bg: 'bg-amber-50/75', text: 'text-amber-700', border: 'border-amber-100', label: 'Admin' },
|
||||||
3: { bg: 'bg-blue-50/75', text: 'text-blue-700', border: 'border-blue-100', label: 'Admin' },
|
3: { bg: 'bg-blue-50/75', text: 'text-blue-700', border: 'border-blue-100', label: 'Admin' },
|
||||||
4: { bg: 'bg-emerald-50/75', text: 'text-emerald-700', border: 'border-emerald-100', label: 'Staff' },
|
4: { bg: 'bg-emerald-50/75', text: 'text-emerald-700', border: 'border-emerald-100', label: 'Staff' },
|
||||||
6: { bg: 'bg-indigo-50/75', text: 'text-indigo-700', border: 'border-indigo-100', label: 'Cashier' },
|
6: { bg: 'bg-indigo-50/75', text: 'text-indigo-700', border: 'border-indigo-100', label: 'Cashier' },
|
||||||
@@ -63,7 +65,7 @@ const ROLE_META: Record<number, { icon: typeof ShieldAlert; desc: string }> = {
|
|||||||
/** Fallback role choices when the app-roles API returns nothing. */
|
/** Fallback role choices when the app-roles API returns nothing. */
|
||||||
const FALLBACK_ROLE_CHOICES = [
|
const FALLBACK_ROLE_CHOICES = [
|
||||||
{ id: 1, label: 'Owner', desc: 'Full business access', icon: ShieldAlert },
|
{ id: 1, label: 'Owner', desc: 'Full business access', icon: ShieldAlert },
|
||||||
{ id: 2, label: 'Manager', desc: 'Operations control', icon: Shield },
|
{ id: 2, label: 'Admin', desc: 'Operations control', icon: Shield },
|
||||||
{ id: 3, label: 'Admin', desc: 'Manage store settings', icon: SlidersHorizontal },
|
{ id: 3, label: 'Admin', desc: 'Manage store settings', icon: SlidersHorizontal },
|
||||||
{ id: 4, label: 'Staff', desc: 'Standard staff duties', icon: User },
|
{ id: 4, label: 'Staff', desc: 'Standard staff duties', icon: User },
|
||||||
{ id: 6, label: 'Cashier', desc: 'Checkout & registers', icon: Coins },
|
{ id: 6, label: 'Cashier', desc: 'Checkout & registers', icon: Coins },
|
||||||
@@ -76,34 +78,81 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
|
|
||||||
// Selectable roles for the Add User modal — driven by the live app-roles API,
|
// Selectable roles for the Add User modal — driven by the live app-roles API,
|
||||||
// matched to local icon/desc styling by roleid; falls back to the static list.
|
// matched to local icon/desc styling by roleid; falls back to the static list.
|
||||||
|
// Selectable roles for the Add User modal - limited to Staff and Rider.
|
||||||
const roleChoices = React.useMemo(() => {
|
const roleChoices = React.useMemo(() => {
|
||||||
const rows = rolesQ.data ?? [];
|
return [
|
||||||
const mapped = rows
|
{ id: 4, label: 'Staff', desc: 'Standard store staff duties', icon: User, configid: 15 },
|
||||||
.map((r) => {
|
{ id: 5, label: 'Rider', desc: 'Delivery fleet rider', icon: Bike, configid: 6 },
|
||||||
const id = fnum((r as Record<string, unknown>).roleid);
|
];
|
||||||
const label =
|
}, []);
|
||||||
fstr((r as Record<string, unknown>).rolename) ||
|
|
||||||
fstr((r as Record<string, unknown>).name) ||
|
|
||||||
roleName(id);
|
|
||||||
const meta = ROLE_META[id];
|
|
||||||
return { id, label, desc: meta?.desc ?? '', icon: meta?.icon ?? User };
|
|
||||||
})
|
|
||||||
.filter((r) => r.id > 0);
|
|
||||||
return mapped.length ? mapped : FALLBACK_ROLE_CHOICES;
|
|
||||||
}, [rolesQ.data]);
|
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [userRoleFilter, setUserRoleFilter] = useState<number | 'ALL'>('ALL');
|
const [userRoleFilter, setUserRoleFilter] = useState<string | 'ALL'>('ALL');
|
||||||
const [showAddUserModal, setShowAddUserModal] = useState(false);
|
const [showAddUserModal, setShowAddUserModal] = useState(false);
|
||||||
const [newUser, setNewUser] = useState({
|
const [newUser, setNewUser] = useState({
|
||||||
firstname: '',
|
firstname: '',
|
||||||
lastname: '',
|
|
||||||
email: '',
|
email: '',
|
||||||
contactno: '',
|
contactno: '',
|
||||||
password: '',
|
|
||||||
roleid: defaultNewUserRole,
|
roleid: defaultNewUserRole,
|
||||||
|
locationid: 0,
|
||||||
|
applocationid: 0,
|
||||||
|
address: '',
|
||||||
|
suburb: '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
postcode: '',
|
||||||
|
latitude: '',
|
||||||
|
longitude: '',
|
||||||
|
shiftid: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stores/branches for this merchant — the new user is bound to the store the
|
||||||
|
// admin picks (its locationid + applocationid go into the create payload).
|
||||||
|
const locationsQ = useFiestaTenantLocations(tenantId);
|
||||||
|
const allStores = (locationsQ.data ?? [])
|
||||||
|
.map((l) => ({
|
||||||
|
locationid: fnum((l as Record<string, unknown>).locationid),
|
||||||
|
applocationid: fnum((l as Record<string, unknown>).applocationid) || 1,
|
||||||
|
name: fstr((l as Record<string, unknown>).locationname) || `Store ${fnum((l as Record<string, unknown>).locationid)}`,
|
||||||
|
address: fstr((l as Record<string, unknown>).address),
|
||||||
|
status: fstr((l as Record<string, unknown>).status),
|
||||||
|
}))
|
||||||
|
.filter((s) => s.locationid > 0);
|
||||||
|
// Prefer Active stores (per the create-staff flow); fall back to all if a tenant
|
||||||
|
// doesn't flag status, so the picker is never empty.
|
||||||
|
const activeStores = allStores.filter((s) => s.status.toLowerCase() === 'active');
|
||||||
|
const storeOptions = activeStores.length ? activeStores : allStores;
|
||||||
|
|
||||||
|
// Auto-bind when the merchant has exactly one store (nothing to choose).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!newUser.locationid && storeOptions.length === 1) {
|
||||||
|
setNewUser((u) => ({ ...u, locationid: storeOptions[0].locationid, applocationid: storeOptions[0].applocationid }));
|
||||||
|
}
|
||||||
|
}, [storeOptions.length, newUser.locationid]);
|
||||||
|
|
||||||
|
// Rider-shift picker — shown only when a rider role is selected (parity with the
|
||||||
|
// merchant_web create form). Shifts come from the live partners/getridershifts.
|
||||||
|
const selectedRole = roleChoices.find((r) => r.id === newUser.roleid);
|
||||||
|
const isRiderRole = (selectedRole?.label || '').toLowerCase().includes('rider') || newUser.roleid === 5;
|
||||||
|
const shiftsQ = useFiestaRiderShifts();
|
||||||
|
const shiftOptions = (shiftsQ.data ?? [])
|
||||||
|
.map((s) => ({ id: fnum((s as Record<string, unknown>).shiftid), label: fstr((s as Record<string, unknown>).shiftname) || `Shift ${fnum((s as Record<string, unknown>).shiftid)}` }))
|
||||||
|
.filter((s) => s.id > 0);
|
||||||
|
|
||||||
|
// Address autocomplete → discrete fields (or clear them when the field is emptied).
|
||||||
|
const handleAddressSelect = (r: AddressResult | null) => {
|
||||||
|
setNewUser((u) => ({
|
||||||
|
...u,
|
||||||
|
address: r?.address ?? '',
|
||||||
|
suburb: r?.suburb ?? '',
|
||||||
|
city: r?.city ?? '',
|
||||||
|
state: r?.state ?? '',
|
||||||
|
postcode: r?.postcode ?? '',
|
||||||
|
latitude: r?.latitude ?? '',
|
||||||
|
longitude: r?.longitude ?? '',
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// Live users mapped to display rows (rendered directly from the query).
|
// Live users mapped to display rows (rendered directly from the query).
|
||||||
const users = (usersQ.data ?? []).map((u, i) => {
|
const users = (usersQ.data ?? []).map((u, i) => {
|
||||||
const shift = fstr(u.shiftname).trim();
|
const shift = fstr(u.shiftname).trim();
|
||||||
@@ -117,7 +166,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
email: fstr(u.email) || fstr(u.authname) || '—',
|
email: fstr(u.email) || fstr(u.authname) || '—',
|
||||||
contact: fstr(u.contactno) || '—',
|
contact: fstr(u.contactno) || '—',
|
||||||
roleid: Number(u.roleid),
|
roleid: Number(u.roleid),
|
||||||
role: roleName(Number(u.roleid)),
|
role: roleName(Number(u.roleid)) === 'Manager' ? 'Admin' : roleName(Number(u.roleid)),
|
||||||
shift: shift && shift !== '-' ? shift : '—',
|
shift: shift && shift !== '-' ? shift : '—',
|
||||||
location: fstr(u.applocation) || fstr(u.city) || 'Coimbatore',
|
location: fstr(u.applocation) || fstr(u.city) || 'Coimbatore',
|
||||||
status: fstr(u.status) || 'Active',
|
status: fstr(u.status) || 'Active',
|
||||||
@@ -132,30 +181,59 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
u.name.toLowerCase().includes(q) ||
|
u.name.toLowerCase().includes(q) ||
|
||||||
u.email.toLowerCase().includes(q) ||
|
u.email.toLowerCase().includes(q) ||
|
||||||
u.contact.toLowerCase().includes(q);
|
u.contact.toLowerCase().includes(q);
|
||||||
const matchesRole = userRoleFilter === 'ALL' || u.roleid === userRoleFilter;
|
const matchesRole = userRoleFilter === 'ALL' || u.role === userRoleFilter;
|
||||||
return matchesSearch && matchesRole;
|
return matchesSearch && matchesRole;
|
||||||
});
|
});
|
||||||
|
|
||||||
const roleOptions = Array.from(new Set(users.map((u) => u.roleid)));
|
const roleOptions = React.useMemo(() => {
|
||||||
|
return Array.from(new Set(users.map((u) => u.role)));
|
||||||
|
}, [users]);
|
||||||
|
|
||||||
const handleCreateUser = async (e: React.FormEvent) => {
|
const handleCreateUser = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newUser.firstname || !newUser.email || !newUser.contactno || !newUser.password) {
|
if (!newUser.firstname || !newUser.email || !newUser.contactno) {
|
||||||
alert('Please provide first name, email, contact number, and a password.');
|
alert('Please provide first name, contact number, and email.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^\d{10}$/.test(newUser.contactno.trim())) {
|
||||||
|
alert('Contact number must be exactly 10 digits.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newUser.email.trim())) {
|
||||||
|
alert('Please enter a valid email address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number(newUser.roleid)) {
|
||||||
|
alert('Please select a role.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newUser.locationid) {
|
||||||
|
alert('Please select the store this user belongs to.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await createUserMut.mutateAsync({
|
await createUserMut.mutateAsync({
|
||||||
firstname: newUser.firstname,
|
firstname: newUser.firstname,
|
||||||
lastname: newUser.lastname,
|
|
||||||
email: newUser.email,
|
email: newUser.email,
|
||||||
contactno: newUser.contactno,
|
contactno: newUser.contactno,
|
||||||
password: newUser.password,
|
|
||||||
roleid: Number(newUser.roleid),
|
roleid: Number(newUser.roleid),
|
||||||
|
configid: selectedRole?.configid ?? 15,
|
||||||
|
// Store binding — sent exactly as the backend expects so the user is tied
|
||||||
|
// to the chosen branch (tenantid + locationid + applocationid).
|
||||||
tenantid: tenantId,
|
tenantid: tenantId,
|
||||||
|
locationid: newUser.locationid,
|
||||||
|
applocationid: newUser.applocationid || 1,
|
||||||
|
address: newUser.address,
|
||||||
|
suburb: newUser.suburb,
|
||||||
|
city: newUser.city,
|
||||||
|
state: newUser.state,
|
||||||
|
postcode: newUser.postcode,
|
||||||
|
latitude: newUser.latitude,
|
||||||
|
longitude: newUser.longitude,
|
||||||
|
shiftid: isRiderRole ? Number(newUser.shiftid) || 0 : 0,
|
||||||
});
|
});
|
||||||
setShowAddUserModal(false);
|
setShowAddUserModal(false);
|
||||||
setNewUser({ firstname: '', lastname: '', email: '', contactno: '', password: '', roleid: defaultNewUserRole });
|
setNewUser({ firstname: '', email: '', contactno: '', roleid: defaultNewUserRole, locationid: 0, applocationid: 0, address: '', suburb: '', city: '', state: '', postcode: '', latitude: '', longitude: '', shiftid: 0 });
|
||||||
alert(`Team member "${newUser.firstname}" added successfully.`);
|
alert(`Team member "${newUser.firstname}" added successfully.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Could not add team member: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
alert(`Could not add team member: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
@@ -199,26 +277,28 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
|
|
||||||
{/* Search & Filter Utility Bar */}
|
{/* Search & Filter Utility Bar */}
|
||||||
<div className="bg-slate-50/50 border border-slate-200/60 p-4 rounded-2xl flex flex-col md:flex-row gap-4 items-stretch md:items-center justify-between select-none">
|
<div className="bg-slate-50/50 border border-slate-200/60 p-4 rounded-2xl flex flex-col md:flex-row gap-4 items-stretch md:items-center justify-between select-none">
|
||||||
<div className="relative w-full md:max-w-sm shrink-0">
|
<div className="relative w-full md:max-w-md shrink-0 group">
|
||||||
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-450">
|
<div className="relative flex items-center bg-white border border-slate-200 rounded-xl transition-all duration-300 shadow-sm focus-within:ring-4 focus-within:ring-purple-150 focus-within:border-purple-600 hover:border-slate-300">
|
||||||
|
<span className="pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-purple-600 transition-colors">
|
||||||
<Search className="w-4.5 h-4.5" />
|
<Search className="w-4.5 h-4.5" />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search team…"
|
placeholder="Search team members by name, email, phone..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="w-full pl-10 pr-9 py-2.5 bg-white border border-slate-200/80 rounded-xl text-sm font-medium text-slate-800 placeholder-slate-405 focus:outline-none focus:ring-4 focus:ring-purple-500/10 focus:border-purple-500 transition-all shadow-sm"
|
className="w-full pl-3 pr-10 py-3 bg-transparent border-none text-xs font-semibold text-slate-800 placeholder-slate-400 focus:outline-none outline-none"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearch('')}
|
onClick={() => setSearch('')}
|
||||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-450 hover:text-slate-700"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 active:scale-95 transition-all p-1 hover:bg-slate-100 rounded-lg"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Role filter capsules */}
|
{/* Role filter capsules */}
|
||||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0 scrollbar-none">
|
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0 scrollbar-none">
|
||||||
@@ -232,17 +312,17 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
>
|
>
|
||||||
All Roles
|
All Roles
|
||||||
</button>
|
</button>
|
||||||
{roleOptions.map((rid) => (
|
{roleOptions.map((roleNameStr) => (
|
||||||
<button
|
<button
|
||||||
key={rid}
|
key={roleNameStr}
|
||||||
onClick={() => setUserRoleFilter(rid)}
|
onClick={() => setUserRoleFilter(roleNameStr)}
|
||||||
className={`px-3.5 py-2 rounded-xl text-xs uppercase tracking-wider font-extrabold transition-all duration-200 cursor-pointer border whitespace-nowrap ${
|
className={`px-3.5 py-2 rounded-xl text-xs uppercase tracking-wider font-extrabold transition-all duration-200 cursor-pointer border whitespace-nowrap ${
|
||||||
userRoleFilter === rid
|
userRoleFilter === roleNameStr
|
||||||
? 'bg-purple-600 text-white border-purple-600 shadow-sm'
|
? 'bg-purple-600 text-white border-purple-600 shadow-sm'
|
||||||
: 'bg-white text-slate-600 border-slate-200/80 hover:text-slate-800 hover:bg-slate-100'
|
: 'bg-white text-slate-600 border-slate-200/80 hover:text-slate-800 hover:bg-slate-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{roleName(rid)}
|
{roleNameStr}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -352,8 +432,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Name Fields */}
|
{/* Firstname */}
|
||||||
<div className="grid grid-cols-2 gap-sm">
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">FIRSTNAME (*)</label>
|
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">FIRSTNAME (*)</label>
|
||||||
<input
|
<input
|
||||||
@@ -365,21 +444,30 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Contactno */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">LAST NAME</label>
|
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">CONTACTNO (*)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||||
|
<Phone size={14} />
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. Rajan"
|
inputMode="numeric"
|
||||||
value={newUser.lastname}
|
maxLength={10}
|
||||||
onChange={(e) => setNewUser({ ...newUser, lastname: e.target.value })}
|
placeholder="e.g. 9988776655"
|
||||||
className="w-full border border-slate-200 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
value={newUser.contactno}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, contactno: e.target.value.replace(/\D/g, '') })}
|
||||||
|
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email & Contact */}
|
{/* Email */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">EMAIL ADDRESS (*)</label>
|
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">EMAIL (*)</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||||
<Mail size={14} />
|
<Mail size={14} />
|
||||||
@@ -395,23 +483,6 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">CONTACT NUMBER (*)</label>
|
|
||||||
<div className="relative">
|
|
||||||
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
|
||||||
<Phone size={14} />
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. 9988776655"
|
|
||||||
value={newUser.contactno}
|
|
||||||
onChange={(e) => setNewUser({ ...newUser, contactno: e.target.value })}
|
|
||||||
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Interactive Role Buttons instead of standard select */}
|
{/* Interactive Role Buttons instead of standard select */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">SELECT ACCOUNT ROLE (*)</label>
|
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">SELECT ACCOUNT ROLE (*)</label>
|
||||||
@@ -441,23 +512,70 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Temporary Password */}
|
{/* Store / branch — binds the user to a specific outlet (tenantid + locationid + applocationid) */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">TEMPORARY PASSWORD (*)</label>
|
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">STORE / BRANCH (*)</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||||
<Lock size={14} />
|
<Store size={14} />
|
||||||
</span>
|
</span>
|
||||||
|
<select
|
||||||
|
value={newUser.locationid}
|
||||||
|
onChange={(e) => {
|
||||||
|
const lid = Number(e.target.value);
|
||||||
|
const s = storeOptions.find((o) => o.locationid === lid);
|
||||||
|
setNewUser({ ...newUser, locationid: lid, applocationid: s?.applocationid ?? 0 });
|
||||||
|
}}
|
||||||
|
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value={0}>{locationsQ.isLoading ? 'Loading stores…' : storeOptions.length ? 'Select a store…' : 'No stores found'}</option>
|
||||||
|
{storeOptions.map((s) => (
|
||||||
|
<option key={s.locationid} value={s.locationid}>{s.name}{s.address ? ` — ${s.address}` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rider shift — only when a rider role is selected */}
|
||||||
|
{isRiderRole && (
|
||||||
|
<div className="space-y-1.5 animate-in slide-in-from-top duration-250">
|
||||||
|
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">RIDER SHIFT</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||||
|
<Clock size={14} />
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={newUser.shiftid}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, shiftid: Number(e.target.value) })}
|
||||||
|
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value={0}>{shiftsQ.isLoading ? 'Loading shifts…' : shiftOptions.length ? 'Select rider shift…' : 'No shifts available'}</option>
|
||||||
|
{shiftOptions.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Address — keyless autocomplete that fills suburb / city / state / postcode */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">ADDRESS</label>
|
||||||
|
<AddressAutocomplete value={newUser.address} onSelect={handleAddressSelect} placeholder="Search address…" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-sm">
|
||||||
|
{(['suburb', 'city', 'state', 'postcode'] as const).map((key) => (
|
||||||
|
<div className="space-y-1.5" key={key}>
|
||||||
|
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">{key.toUpperCase()}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Set password credentials"
|
value={newUser[key]}
|
||||||
value={newUser.password}
|
onChange={(e) => setNewUser({ ...newUser, [key]: e.target.value })}
|
||||||
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
|
className="w-full border border-slate-200 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||||
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold font-mono text-sm shadow-sm"
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export function StampCell({ date, time }: { date?: string; time?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Search pill ──────────────────────────────────────────────────────────────────
|
// ── Search pill ──────────────────────────────────────────────────────────────────
|
||||||
export function SearchPill({ value, onChange, placeholder, color = BRAND }: { value: string; onChange: (v: string) => void; placeholder?: string; color?: string }) {
|
export function SearchPill({ value, onChange, placeholder, color = BRAND, inputRef }: { value: string; onChange: (v: string) => void; placeholder?: string; color?: string; inputRef?: React.Ref<HTMLInputElement> }) {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round">
|
<svg className="absolute left-3 top-1/2 -translate-y-1/2" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round">
|
||||||
@@ -253,6 +253,7 @@ export function SearchPill({ value, onChange, placeholder, color = BRAND }: { va
|
|||||||
<path d="m21 21-4.3-4.3" />
|
<path d="m21 21-4.3-4.3" />
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
|||||||
24
src/main.tsx
24
src/main.tsx
@@ -4,14 +4,30 @@ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
|||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
// Single shared query client. Sensible defaults for a dashboard: cache for a
|
// How often every page silently re-syncs with the backend. Orders/deliveries
|
||||||
// minute, one retry, and no refetch storm when the window regains focus.
|
// statuses change out-of-band (riders accept/pick/deliver, customers place
|
||||||
|
// orders), so the whole console auto-refreshes on this cadence.
|
||||||
|
const AUTO_REFRESH_MS = 30_000;
|
||||||
|
|
||||||
|
// Single shared query client. Auto-refresh is wired here once so EVERY page
|
||||||
|
// (current and future) inherits it — no per-component polling needed:
|
||||||
|
// • refetchInterval — poll the backend every AUTO_REFRESH_MS so status/order
|
||||||
|
// changes appear without a manual reload.
|
||||||
|
// • refetchIntervalInBackground:false — pause polling while the tab is hidden
|
||||||
|
// (saves API calls); it resumes + immediately refetches when the tab is shown.
|
||||||
|
// • refetchOnWindowFocus / refetchOnReconnect — refresh the instant the user
|
||||||
|
// returns to the tab or the network comes back.
|
||||||
|
// staleTime is kept below the interval so focus/mount refetches aren't skipped.
|
||||||
|
// Disabled queries (enabled:false, e.g. closed-modal detail fetches) never poll.
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 60_000,
|
staleTime: 15_000,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
refetchOnWindowFocus: false,
|
refetchInterval: AUTO_REFRESH_MS,
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* `./queries`, which add caching, dedup, and loading/error state.
|
* `./queries`, which add caching, dedup, and loading/error state.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { cleanTenantLocations } from './fiestaApi';
|
||||||
|
|
||||||
const HASURA_BASE = '/hasura';
|
const HASURA_BASE = '/hasura';
|
||||||
|
|
||||||
/** Tenant whose live data the dashboard displays. */
|
/** Tenant whose live data the dashboard displays. */
|
||||||
@@ -185,9 +187,14 @@ export async function getTenantInfo(tenantid: number): Promise<Row | null> {
|
|||||||
return firstRow(await hasuraGet('gettenantinfo', { tenantid }));
|
return firstRow(await hasuraGet('gettenantinfo', { tenantid }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /gettenantlocations?tenantid= — physical locations linked to a tenant. */
|
/**
|
||||||
|
* /gettenantlocations?tenantid= — physical locations linked to a tenant.
|
||||||
|
* Piped through the shared cleaner (dedupe + strip test rows) so it matches the
|
||||||
|
* Fiesta source. The Hasura and Fiesta tenant-location tables share the same DB,
|
||||||
|
* so both return the same duplicate/junk rows without this.
|
||||||
|
*/
|
||||||
export async function getTenantLocations(tenantid: number): Promise<Row[]> {
|
export async function getTenantLocations(tenantid: number): Promise<Row[]> {
|
||||||
return toRows(await hasuraGet('gettenantlocations', { tenantid }));
|
return cleanTenantLocations(toRows(await hasuraGet('gettenantlocations', { tenantid })));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /getcustomersbytenant?tenantid=&limit=&offset= — customers for a tenant. */
|
/** /getcustomersbytenant?tenantid=&limit=&offset= — customers for a tenant. */
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ import { firstRow, num, str, type Row } from './fiestaApi';
|
|||||||
// ── Backend login config ──────────────────────────────────────────────────────
|
// ── Backend login config ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fiesta application login. Routed through the Vite `/fiesta` proxy →
|
* Fiesta application login — called directly at
|
||||||
* https://fiesta.nearle.app/live/api/v1/web/users/applogin.
|
* https://fiesta.nearle.app/live/api/v1/web/users/applogin (CORS-enabled).
|
||||||
* Observed shape:
|
* Observed shape:
|
||||||
* request: { authname: <email>, password: <password>, configid: 1, userfcmtoken: null }
|
* request: { authname: <email>, password: <password>, configid: 1, userfcmtoken: null }
|
||||||
* failure: { code: 409, message: "Invalid Email", status: false }
|
* failure: { code: 409, message: "Invalid Email", status: false }
|
||||||
* success: status !== false (Fiesta envelope, optionally with `details`)
|
* success: status !== false (Fiesta envelope, optionally with `details`)
|
||||||
*/
|
*/
|
||||||
const LOGIN_ENDPOINT = '/fiesta/live/api/v1/web/users/applogin';
|
const LOGIN_ENDPOINT = 'https://fiesta.nearle.app/live/api/v1/web/users/applogin';
|
||||||
|
|
||||||
/** Request body field names the endpoint expects for the credentials. */
|
/** Request body field names the endpoint expects for the credentials. */
|
||||||
const REQUEST_FIELDS = {
|
const REQUEST_FIELDS = {
|
||||||
@@ -54,6 +54,10 @@ const RESPONSE_FIELDS = {
|
|||||||
email: 'email',
|
email: 'email',
|
||||||
contactno: 'contactno',
|
contactno: 'contactno',
|
||||||
userid: 'userid',
|
userid: 'userid',
|
||||||
|
// Tenant binding: the merchant this user belongs to. Drives every Fiesta query
|
||||||
|
// scope (locations, summaries, orders, stock) so a user only sees their own
|
||||||
|
// tenant's data — not the shared sandbox tenant the constant defaults to.
|
||||||
|
tenantid: 'tenantid',
|
||||||
// Store binding: a non-admin user is allocated to an app-location via
|
// Store binding: a non-admin user is allocated to an app-location via
|
||||||
// applocationid; `applocation` is its human-readable name (e.g. "Coimbatore").
|
// applocationid; `applocation` is its human-readable name (e.g. "Coimbatore").
|
||||||
// locationid/locationname are captured when present (often 0/absent on the
|
// locationid/locationname are captured when present (often 0/absent on the
|
||||||
@@ -83,6 +87,8 @@ export interface AuthUser {
|
|||||||
roleid?: number;
|
roleid?: number;
|
||||||
/** Phone number on the user record. */
|
/** Phone number on the user record. */
|
||||||
contactno?: string;
|
contactno?: string;
|
||||||
|
/** The merchant/tenant this user belongs to — scopes every Fiesta query. */
|
||||||
|
tenantid?: number;
|
||||||
/** The app-location this user is allocated to. */
|
/** The app-location this user is allocated to. */
|
||||||
applocationid?: number;
|
applocationid?: number;
|
||||||
/** App-location / zone name on the user record (e.g. "Coimbatore"). */
|
/** App-location / zone name on the user record (e.g. "Coimbatore"). */
|
||||||
@@ -224,6 +230,7 @@ export function buildAuthUser(row: Row | null, email: string): AuthUser {
|
|||||||
userid: row && row[RESPONSE_FIELDS.userid] != null ? num(row[RESPONSE_FIELDS.userid]) : undefined,
|
userid: row && row[RESPONSE_FIELDS.userid] != null ? num(row[RESPONSE_FIELDS.userid]) : undefined,
|
||||||
roleid,
|
roleid,
|
||||||
contactno: contactno || undefined,
|
contactno: contactno || undefined,
|
||||||
|
tenantid: row && row[RESPONSE_FIELDS.tenantid] != null ? num(row[RESPONSE_FIELDS.tenantid]) : undefined,
|
||||||
applocationid:
|
applocationid:
|
||||||
row && row[RESPONSE_FIELDS.applocationid] != null ? num(row[RESPONSE_FIELDS.applocationid]) : undefined,
|
row && row[RESPONSE_FIELDS.applocationid] != null ? num(row[RESPONSE_FIELDS.applocationid]) : undefined,
|
||||||
applocation: applocation || undefined,
|
applocation: applocation || undefined,
|
||||||
|
|||||||
@@ -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: '' },
|
|
||||||
];
|
|
||||||
@@ -9,16 +9,16 @@
|
|||||||
* REST tab). This is the operational backend: order/delivery/location summaries,
|
* REST tab). This is the operational backend: order/delivery/location summaries,
|
||||||
* the deliveries board, riders, stock statements, and customers.
|
* the deliveries board, riders, stock statements, and customers.
|
||||||
*
|
*
|
||||||
* Requests go through the Vite dev proxy at `/fiesta/*`, which forwards to
|
* Requests go directly to `https://fiesta.nearle.app/*` — Fiesta is CORS-enabled
|
||||||
* `https://fiesta.nearle.app/*` (see vite.config.ts). Fiesta is CORS-enabled and
|
* and needs no auth header for these read endpoints, so no dev proxy is required.
|
||||||
* needs no auth header for these read endpoints.
|
|
||||||
*
|
*
|
||||||
* This sits alongside `./api` (the Hasura/workolik REST surface the dashboard
|
* This sits alongside `./api` (the Hasura/workolik REST surface the dashboard
|
||||||
* uses). Components should call the TanStack hooks in `./fiestaQueries`, not
|
* uses). Components should call the TanStack hooks in `./fiestaQueries`, not
|
||||||
* these functions directly.
|
* these functions directly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const FIESTA_BASE = '/fiesta/live/api/v1/web';
|
const FIESTA_BASE = 'https://fiesta.nearle.app/live/api/v1/web';
|
||||||
|
const FIESTA_MOB_BASE = 'https://fiesta.nearle.app/live/api/v1/mob';
|
||||||
|
|
||||||
/** Tenant / location scope shared by the merchant console (Ragul Stores, Coimbatore). */
|
/** Tenant / location scope shared by the merchant console (Ragul Stores, Coimbatore). */
|
||||||
export const FIESTA_TENANT_ID = 1087;
|
export const FIESTA_TENANT_ID = 1087;
|
||||||
@@ -29,6 +29,19 @@ export const FIESTA_PRIMARY_LOCATION_ID = 1097;
|
|||||||
export type Row = Record<string, unknown>;
|
export type Row = Record<string, unknown>;
|
||||||
type QueryParams = Record<string, string | number | undefined | null>;
|
type QueryParams = Record<string, string | number | undefined | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact payload the nearledaily consumer app expects when its in-app scanner
|
||||||
|
* reads a store QR: a JSON object `{"tenantid":N,"locationid":N}`. The app parses
|
||||||
|
* this and resolves the outlet from it.
|
||||||
|
*
|
||||||
|
* IMPORTANT: it must be this JSON shape, NOT a URL — the app rejects a URL with
|
||||||
|
* "invalid QR code content". Keep it to exactly these two keys to match the app's
|
||||||
|
* schema; extra keys risk strict-schema rejection on the app side.
|
||||||
|
*/
|
||||||
|
export function buildStoreQrPayload(opts: { tenantid: number; locationid: number }): string {
|
||||||
|
return JSON.stringify({ tenantid: opts.tenantid, locationid: opts.locationid });
|
||||||
|
}
|
||||||
|
|
||||||
async function fiestaGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> {
|
async function fiestaGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
Object.entries(params).forEach(([k, v]) => {
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
@@ -112,13 +125,14 @@ export interface FiestaOrderSummary {
|
|||||||
tenantname?: string;
|
tenantname?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /orders/getordersummary?tenantid=&fromdate=&todate= — flat order counts. */
|
/** /orders/getordersummary?tenantid=&locationid=&fromdate=&todate= — flat order counts. */
|
||||||
export async function getOrderSummary(
|
export async function getOrderSummary(
|
||||||
tenantid: number,
|
tenantid: number,
|
||||||
fromdate: string,
|
fromdate: string,
|
||||||
todate: string,
|
todate: string,
|
||||||
|
locationid?: number,
|
||||||
): Promise<FiestaOrderSummary | null> {
|
): Promise<FiestaOrderSummary | null> {
|
||||||
const row = firstRow<Row>(await fiestaGet('orders/getordersummary', { tenantid, fromdate, todate }));
|
const row = firstRow<Row>(await fiestaGet('orders/getordersummary', { tenantid, locationid, fromdate, todate }));
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return {
|
return {
|
||||||
total: num(row.total),
|
total: num(row.total),
|
||||||
@@ -162,21 +176,27 @@ export async function getOrderInsight(tenantid: number): Promise<Row[]> {
|
|||||||
return toRows(await fiestaGet('orders/getorderinsight', { tenantid }));
|
return toRows(await fiestaGet('orders/getorderinsight', { tenantid }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /orders/getorders?tenantid=&status=&fromdate=&todate=&pageno=&pagesize= — orders board. */
|
/** /orders/getorders?tenantid=&locationid=&applocationid=&status=&fromdate=&todate=&keyword=&pageno=&pagesize= — orders board. */
|
||||||
export async function getOrders(opts: {
|
export async function getOrders(opts: {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
status: string;
|
status: string;
|
||||||
fromdate: string;
|
fromdate: string;
|
||||||
todate: string;
|
todate: string;
|
||||||
|
locationid?: number;
|
||||||
|
applocationid?: number;
|
||||||
|
keyword?: string;
|
||||||
pageno?: number;
|
pageno?: number;
|
||||||
pagesize?: number;
|
pagesize?: number;
|
||||||
}): Promise<Row[]> {
|
}): Promise<Row[]> {
|
||||||
return toRows(
|
return toRows(
|
||||||
await fiestaGet('orders/getorders', {
|
await fiestaGet('orders/getorders', {
|
||||||
tenantid: opts.tenantid,
|
tenantid: opts.tenantid,
|
||||||
|
locationid: opts.locationid,
|
||||||
|
applocationid: opts.applocationid,
|
||||||
status: opts.status,
|
status: opts.status,
|
||||||
fromdate: opts.fromdate,
|
fromdate: opts.fromdate,
|
||||||
todate: opts.todate,
|
todate: opts.todate,
|
||||||
|
keyword: opts.keyword,
|
||||||
pageno: opts.pageno ?? 1,
|
pageno: opts.pageno ?? 1,
|
||||||
pagesize: opts.pagesize ?? 20,
|
pagesize: opts.pagesize ?? 20,
|
||||||
}),
|
}),
|
||||||
@@ -185,7 +205,15 @@ export async function getOrders(opts: {
|
|||||||
|
|
||||||
/** /orders/getorderdetails?orderheaderid= — line items for a single order. */
|
/** /orders/getorderdetails?orderheaderid= — line items for a single order. */
|
||||||
export async function getOrderDetails(orderheaderid: number | string): Promise<Row[]> {
|
export async function getOrderDetails(orderheaderid: number | string): Promise<Row[]> {
|
||||||
return toRows(await fiestaGet('orders/getorderdetails', { orderheaderid }));
|
let cleanId = String(orderheaderid).trim();
|
||||||
|
if (cleanId.toUpperCase().startsWith('DLV-')) {
|
||||||
|
cleanId = cleanId.substring(4);
|
||||||
|
}
|
||||||
|
cleanId = cleanId.split('-')[0];
|
||||||
|
const numericId = Number(cleanId);
|
||||||
|
const finalId = Number.isInteger(numericId) && numericId > 0 ? numericId : orderheaderid;
|
||||||
|
|
||||||
|
return toRows(await fiestaGet('orders/getorderdetails', { orderheaderid: finalId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /orders/getorders?customerid=&status=&pageno=&pagesize= — one customer's order history. */
|
/** /orders/getorders?customerid=&status=&pageno=&pagesize= — one customer's order history. */
|
||||||
@@ -221,17 +249,19 @@ export interface FiestaDeliverySummary {
|
|||||||
cancelled: number;
|
cancelled: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /deliveries/deliverysummary?tenantid=&applocationid=&fromdate=&todate= — dispatch counts. */
|
/** /deliveries/deliverysummary?tenantid=&applocationid=&locationid=&fromdate=&todate= — dispatch counts. */
|
||||||
export async function getDeliverySummary(opts: {
|
export async function getDeliverySummary(opts: {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
applocationid?: number;
|
applocationid?: number;
|
||||||
|
locationid?: number;
|
||||||
fromdate: string;
|
fromdate: string;
|
||||||
todate: string;
|
todate: string;
|
||||||
}): Promise<FiestaDeliverySummary | null> {
|
}): Promise<FiestaDeliverySummary | null> {
|
||||||
const row = firstRow<Row>(
|
const row = firstRow<Row>(
|
||||||
await fiestaGet('deliveries/deliverysummary', {
|
await fiestaGet('deliveries/deliverysummary', {
|
||||||
tenantid: opts.tenantid,
|
tenantid: opts.tenantid,
|
||||||
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
|
applocationid: opts.applocationid, // only sent when provided (no forced default)
|
||||||
|
locationid: opts.locationid,
|
||||||
fromdate: opts.fromdate,
|
fromdate: opts.fromdate,
|
||||||
todate: opts.todate,
|
todate: opts.todate,
|
||||||
}),
|
}),
|
||||||
@@ -250,19 +280,40 @@ export async function getDeliverySummary(opts: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /deliveries/getdeliveries?tenantid=&fromdate=&todate= — the master deliveries board. */
|
/** /deliveries/getdeliveries?tenantid=&applocationid=&locationid=&status=&fromdate=&todate=&keyword=&pageno=&pagesize= — the master deliveries board. */
|
||||||
export async function getDeliveries(opts: {
|
export async function getDeliveries(opts: {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
fromdate: string;
|
fromdate: string;
|
||||||
todate: string;
|
todate: string;
|
||||||
|
status?: string;
|
||||||
|
locationid?: number;
|
||||||
|
applocationid?: number;
|
||||||
|
keyword?: string;
|
||||||
|
pageno?: number;
|
||||||
|
pagesize?: number;
|
||||||
}): Promise<Row[]> {
|
}): Promise<Row[]> {
|
||||||
return toRows(
|
const rows = toRows(
|
||||||
await fiestaGet('deliveries/getdeliveries', {
|
await fiestaGet('deliveries/getdeliveries', {
|
||||||
tenantid: opts.tenantid,
|
tenantid: opts.tenantid,
|
||||||
|
// NOTE: do NOT send `locationid` to getdeliveries — the backend's locationid
|
||||||
|
// filter on THIS endpoint is broken: passing a real outlet id returns []
|
||||||
|
// (it doesn't match against the row's own `locationid`), even though
|
||||||
|
// deliverysummary honours the same id and the rows clearly carry it. So we
|
||||||
|
// fetch tenant-wide here and scope by locationid client-side below; the KPI
|
||||||
|
// strip (deliverysummary) keeps using the working server-side filter.
|
||||||
|
applocationid: opts.applocationid,
|
||||||
|
// The backend treats `status` as a LITERAL orderstatus filter — passing
|
||||||
|
// 'all' matches nothing (returns []). Send empty to fetch every status and
|
||||||
|
// let the board filter client-side by its status tabs.
|
||||||
|
status: !opts.status || opts.status === 'all' ? '' : opts.status,
|
||||||
fromdate: opts.fromdate,
|
fromdate: opts.fromdate,
|
||||||
todate: opts.todate,
|
todate: opts.todate,
|
||||||
|
keyword: opts.keyword,
|
||||||
|
pageno: opts.pageno ?? 1,
|
||||||
|
pagesize: opts.pagesize ?? 200,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
return opts.locationid ? rows.filter((r) => num(r.locationid) === opts.locationid) : rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /deliveries/getdeliveryinsight?tenantid= — daily delivery insight. */
|
/** /deliveries/getdeliveryinsight?tenantid= — daily delivery insight. */
|
||||||
@@ -312,35 +363,214 @@ export async function getFleetSummary(opts: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `YYYY-MM-DD HH:mm:ss` — the timestamp format the delivery endpoints expect. */
|
||||||
|
function nowStamp(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a delivery record from a getorders row for POST /createdeliveries. The
|
||||||
|
* backend keys the order (orderheaderid) and the rider (userid), copies the
|
||||||
|
* pickup/drop snapshot, and must carry the SAME tenant/partner/applocation as
|
||||||
|
* the order so the assignment is valid. Order field names map 1:1 (Go's JSON
|
||||||
|
* decode is case-insensitive, so `pickupaddress` satisfies `Pickupaddress`).
|
||||||
|
*/
|
||||||
|
function deliveryFromOrder(o: Row, userid: number, assigntime: string): Row {
|
||||||
|
return {
|
||||||
|
orderheaderid: num(o.orderheaderid),
|
||||||
|
orderid: str(o.orderid),
|
||||||
|
applocationid: num(o.applocationid),
|
||||||
|
configid: num(o.configid) || 1,
|
||||||
|
partnerid: num(o.partnerid),
|
||||||
|
tenantid: num(o.tenantid),
|
||||||
|
moduleid: num(o.moduleid),
|
||||||
|
locationid: num(o.locationid),
|
||||||
|
categoryid: num(o.categoryid),
|
||||||
|
subcategoryid: num(o.subcategoryid),
|
||||||
|
userid, // the assigned rider
|
||||||
|
customerid: num(o.customerid),
|
||||||
|
orderstatus: 'pending',
|
||||||
|
assigntime,
|
||||||
|
// The Orders API exposes the scheduled delivery date as `deliverytime` (there
|
||||||
|
// is no `deliverydate` on an order row). Copy it through so the new delivery
|
||||||
|
// lands in the Deliveries board's date window — falling back to the order date
|
||||||
|
// and finally the assign timestamp so the row is never written date-less
|
||||||
|
// (a date-less delivery is excluded by getdeliveries' from/to filter).
|
||||||
|
deliverydate: str(o.deliverydate) || str(o.deliverytime) || str(o.orderdate) || assigntime,
|
||||||
|
itemcount: num(o.itemcount),
|
||||||
|
orderamount: num(o.orderamount) || num(o.deliveryamt),
|
||||||
|
deliveryamt: num(o.deliveryamt),
|
||||||
|
deliverycharges: num(o.deliverycharge) || num(o.deliverycharges),
|
||||||
|
paymenttype: num(o.paymenttype),
|
||||||
|
ordernotes: str(o.ordernotes),
|
||||||
|
pickupcustomer: str(o.pickupcustomer) || str(o.tenantname),
|
||||||
|
pickupcontactno: str(o.pickupcontactno),
|
||||||
|
pickupaddress: str(o.pickupaddress),
|
||||||
|
pickuplocationid: num(o.pickuplocationid),
|
||||||
|
pickuplat: str(o.pickuplat),
|
||||||
|
pickuplon: str(o.pickuplong) || str(o.pickuplon),
|
||||||
|
deliverycustomerid: num(o.deliverycustomerid),
|
||||||
|
deliverylocationid: num(o.deliverylocationid),
|
||||||
|
deliverycustomer: str(o.deliverycustomer),
|
||||||
|
deliverycontactno: str(o.deliverycontactno),
|
||||||
|
deliveryaddress: str(o.deliveryaddress),
|
||||||
|
deliverylat: str(o.deliverylat),
|
||||||
|
deliverylong: str(o.deliverylong),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign a rider to one or more orders — the CORRECT flow per the backend:
|
||||||
|
* • orders with no delivery yet (`deliveryid == 0`, i.e. freshly created) →
|
||||||
|
* POST /deliveries/createdeliveries (one batched call). This creates the
|
||||||
|
* delivery, enqueues it, AND flips the order to `pending`.
|
||||||
|
* • orders that already have a delivery → PUT /deliveries/updatedelivery to
|
||||||
|
* re-point the rider.
|
||||||
|
* The rider (userid) MUST belong to the same tenant/partner as the orders, or
|
||||||
|
* the backend rejects the assignment — that scoping is enforced on the rider
|
||||||
|
* list (see getRiders' partnerid).
|
||||||
|
*/
|
||||||
|
export async function assignRiderToOrders(
|
||||||
|
userid: number,
|
||||||
|
orders: Row[],
|
||||||
|
): Promise<{ ok: number; failed: number; total: number }> {
|
||||||
|
const assigntime = nowStamp();
|
||||||
|
const toCreate = orders.filter((o) => !num(o.deliveryid));
|
||||||
|
const toUpdate = orders.filter((o) => num(o.deliveryid));
|
||||||
|
let ok = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
if (toCreate.length) {
|
||||||
|
try {
|
||||||
|
await fiestaSend('deliveries/createdeliveries', 'POST', toCreate.map((o) => deliveryFromOrder(o, userid, assigntime)));
|
||||||
|
ok += toCreate.length;
|
||||||
|
} catch {
|
||||||
|
failed += toCreate.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toUpdate.length) {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
toUpdate.map((o) =>
|
||||||
|
fiestaSend('deliveries/updatedelivery', 'PUT', {
|
||||||
|
userid,
|
||||||
|
deliveryid: num(o.deliveryid),
|
||||||
|
orderheaderid: num(o.orderheaderid),
|
||||||
|
orderstatus: 'pending',
|
||||||
|
assigntime,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ok += results.filter((r) => r.status === 'fulfilled').length;
|
||||||
|
failed += results.filter((r) => r.status === 'rejected').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok, failed, total: orders.length };
|
||||||
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// PARTNERS / RIDERS
|
// PARTNERS / RIDERS
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/** /partners/getriders?applocationid=&tenantid= — active rider fleet. */
|
/**
|
||||||
|
* /partners/getriders?applocationid=&tenantid=&partnerid= — active rider fleet.
|
||||||
|
* Scoped by tenant AND partner: a rider belongs to one tenant/partner, so an
|
||||||
|
* order can only be assigned to a rider sharing its partnerid. Passing the
|
||||||
|
* order's partnerid keeps the assignable list correct (an out-of-tenant rider
|
||||||
|
* simply won't appear, which is the intended guard).
|
||||||
|
*/
|
||||||
export async function getRiders(opts: {
|
export async function getRiders(opts: {
|
||||||
applocationid?: number;
|
applocationid?: number;
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
|
partnerid?: number;
|
||||||
}): Promise<Row[]> {
|
}): Promise<Row[]> {
|
||||||
return toRows(
|
return toRows(
|
||||||
await fiestaGet('partners/getriders', {
|
await fiestaGet('partners/getriders', {
|
||||||
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
|
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
|
||||||
tenantid: opts.tenantid,
|
tenantid: opts.tenantid,
|
||||||
|
partnerid: opts.partnerid,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /partners/getridershifts?applocationid= — rider shift records. */
|
/** /partners/getridershifts?applocationid= — rider shift records. */
|
||||||
export async function getRiderShifts(applocationid: number = FIESTA_APPLOCATION_ID): Promise<Row[]> {
|
export async function getRiderShifts(applocationid: number = FIESTA_APPLOCATION_ID): Promise<Row[]> {
|
||||||
return toRows(await fiestaGet('partners/getridershifts', { applocationid }));
|
return toRows(await fiestaGet('partners/getridershifts/', { applocationid }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// TENANTS / CUSTOMERS
|
// TENANTS / CUSTOMERS
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/** /tenants/gettenantlocations?tenantid= — outlet locations for a tenant. */
|
/**
|
||||||
|
* Throwaway/test email providers. A location whose contact email is on one of
|
||||||
|
* these is a sandbox record, never a real outlet — used to drop test data.
|
||||||
|
*/
|
||||||
|
const DISPOSABLE_EMAIL_DOMAINS = new Set([
|
||||||
|
'mailinator.com',
|
||||||
|
'mailinator.net',
|
||||||
|
'example.com',
|
||||||
|
'example.org',
|
||||||
|
'test.com',
|
||||||
|
'yopmail.com',
|
||||||
|
'guerrillamail.com',
|
||||||
|
'10minutemail.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tenant-locations endpoint for some tenants returns junk: the primary
|
||||||
|
* outlet duplicated several times, plus orphan test records geocoded to random
|
||||||
|
* countries (e.g. "Deborah Lara, Spain", "power, Ireland") with throwaway
|
||||||
|
* emails. This strips both so the registry/inventory show only real outlets.
|
||||||
|
*
|
||||||
|
* The filter is self-calibrating (no hardcoded names/ids): it derives the
|
||||||
|
* tenant's operating region from the most common state among its outlets, then
|
||||||
|
* drops rows that either use a disposable email or sit outside that region. If
|
||||||
|
* the region can't be established (no state data), nothing is region-filtered —
|
||||||
|
* we'd rather show an extra row than hide a genuine outlet.
|
||||||
|
*/
|
||||||
|
export function cleanTenantLocations(rows: Row[]): Row[] {
|
||||||
|
// 1. Dedupe by locationid — the API repeats the primary outlet.
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const deduped = rows.filter((r) => {
|
||||||
|
const id = num(r.locationid);
|
||||||
|
if (!id || seen.has(id)) return false;
|
||||||
|
seen.add(id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Find the tenant's home region (plurality of `state`).
|
||||||
|
const stateCounts = new Map<string, number>();
|
||||||
|
for (const r of deduped) {
|
||||||
|
const st = str(r.state).trim().toLowerCase();
|
||||||
|
if (st) stateCounts.set(st, (stateCounts.get(st) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
let homeState = '';
|
||||||
|
let max = 0;
|
||||||
|
for (const [st, c] of stateCounts) {
|
||||||
|
if (c > max) {
|
||||||
|
max = c;
|
||||||
|
homeState = st;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Drop disposable-email rows and out-of-region rows.
|
||||||
|
return deduped.filter((r) => {
|
||||||
|
const emailDomain = (str(r.email).split('@')[1] ?? '').trim().toLowerCase();
|
||||||
|
if (emailDomain && DISPOSABLE_EMAIL_DOMAINS.has(emailDomain)) return false;
|
||||||
|
if (homeState) {
|
||||||
|
const st = str(r.state).trim().toLowerCase();
|
||||||
|
if (st && st !== homeState) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /tenants/gettenantlocations?tenantid= — outlet locations for a tenant (test rows stripped). */
|
||||||
export async function getTenantLocations(tenantid: number): Promise<Row[]> {
|
export async function getTenantLocations(tenantid: number): Promise<Row[]> {
|
||||||
return toRows(await fiestaGet('tenants/gettenantlocations', { tenantid }));
|
return cleanTenantLocations(toRows(await fiestaGet('tenants/gettenantlocations', { tenantid })));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /tenants/getalltenants?applocationid=&status=&pageno=&pagesize= — active tenants. */
|
/** /tenants/getalltenants?applocationid=&status=&pageno=&pagesize= — active tenants. */
|
||||||
@@ -360,7 +590,27 @@ export async function getAllTenants(opts: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /customers/gettenantcustomers?tenantid=&locationid=&pageno=&pagesize=&keyword= */
|
/**
|
||||||
|
* Collapse the gettenantcustomers rows to one per customer. The endpoint returns
|
||||||
|
* one row per saved DELIVERY ADDRESS (each carries its own deliverylocationid /
|
||||||
|
* address), so a customer with several addresses repeats many times. Key by
|
||||||
|
* customerid (fall back to contactno), preferring the row flagged primaryaddress;
|
||||||
|
* rows with no identity at all are kept as-is so nothing is silently dropped.
|
||||||
|
*/
|
||||||
|
export function dedupeCustomers(rows: Row[]): Row[] {
|
||||||
|
const byCustomer = new Map<string, Row>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const cid = num(r.customerid);
|
||||||
|
const key = cid ? `c${cid}` : (str(r.contactno) ? `p${str(r.contactno)}` : `x${byCustomer.size}`);
|
||||||
|
const existing = byCustomer.get(key);
|
||||||
|
if (!existing || (num(r.primaryaddress) && !num(existing.primaryaddress))) {
|
||||||
|
byCustomer.set(key, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byCustomer.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /customers/gettenantcustomers?tenantid=&locationid=&pageno=&pagesize=&keyword= (deduped per customer). */
|
||||||
export async function getTenantCustomers(opts: {
|
export async function getTenantCustomers(opts: {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
locationid: number;
|
locationid: number;
|
||||||
@@ -368,7 +618,7 @@ export async function getTenantCustomers(opts: {
|
|||||||
pageno?: number;
|
pageno?: number;
|
||||||
pagesize?: number;
|
pagesize?: number;
|
||||||
}): Promise<Row[]> {
|
}): Promise<Row[]> {
|
||||||
return toRows(
|
return dedupeCustomers(toRows(
|
||||||
await fiestaGet('customers/gettenantcustomers', {
|
await fiestaGet('customers/gettenantcustomers', {
|
||||||
tenantid: opts.tenantid,
|
tenantid: opts.tenantid,
|
||||||
locationid: opts.locationid,
|
locationid: opts.locationid,
|
||||||
@@ -376,7 +626,7 @@ export async function getTenantCustomers(opts: {
|
|||||||
pageno: opts.pageno ?? 1,
|
pageno: opts.pageno ?? 1,
|
||||||
pagesize: opts.pagesize ?? 20,
|
pagesize: opts.pagesize ?? 20,
|
||||||
}),
|
}),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -540,8 +790,13 @@ export interface CreateUserInput {
|
|||||||
lastname?: string;
|
lastname?: string;
|
||||||
email: string;
|
email: string;
|
||||||
contactno: string;
|
contactno: string;
|
||||||
password: string;
|
/** Optional — merchant_web's create form doesn't collect one. */
|
||||||
|
password?: string;
|
||||||
roleid: number;
|
roleid: number;
|
||||||
|
/** Role config (the selected role's configid) — matches merchant_web's create payload. */
|
||||||
|
configid?: number;
|
||||||
|
/** Business module id (merchant_web sends the logged-in user's; 0 when absent). */
|
||||||
|
moduleid?: number;
|
||||||
dialcode?: string;
|
dialcode?: string;
|
||||||
pin?: number;
|
pin?: number;
|
||||||
address?: string;
|
address?: string;
|
||||||
@@ -549,6 +804,10 @@ export interface CreateUserInput {
|
|||||||
city?: string;
|
city?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
postcode?: string;
|
postcode?: string;
|
||||||
|
latitude?: string;
|
||||||
|
longitude?: string;
|
||||||
|
/** Rider shift (only meaningful for rider-role users). */
|
||||||
|
shiftid?: number;
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
locationid?: number;
|
locationid?: number;
|
||||||
applocationid?: number;
|
applocationid?: number;
|
||||||
@@ -561,17 +820,22 @@ export async function createUser(input: CreateUserInput): Promise<Row> {
|
|||||||
authname: input.email,
|
authname: input.email,
|
||||||
firstname: input.firstname,
|
firstname: input.firstname,
|
||||||
lastname: input.lastname ?? '',
|
lastname: input.lastname ?? '',
|
||||||
password: input.password,
|
password: input.password ?? '',
|
||||||
email: input.email,
|
email: input.email,
|
||||||
dialcode: input.dialcode ?? '+91',
|
dialcode: input.dialcode ?? '+91',
|
||||||
contactno: input.contactno,
|
contactno: input.contactno,
|
||||||
roleid: input.roleid,
|
roleid: input.roleid,
|
||||||
|
configid: input.configid ?? 15,
|
||||||
|
moduleid: input.moduleid ?? 0,
|
||||||
pin: input.pin ?? 0,
|
pin: input.pin ?? 0,
|
||||||
address: input.address ?? '',
|
address: input.address ?? '',
|
||||||
suburb: input.suburb ?? '',
|
suburb: input.suburb ?? '',
|
||||||
city: input.city ?? '',
|
city: input.city ?? '',
|
||||||
state: input.state ?? '',
|
state: input.state ?? '',
|
||||||
postcode: input.postcode ?? '',
|
postcode: input.postcode ?? '',
|
||||||
|
latitude: input.latitude ?? '',
|
||||||
|
longitude: input.longitude ?? '',
|
||||||
|
shiftid: input.shiftid ?? 0,
|
||||||
tenantid: input.tenantid,
|
tenantid: input.tenantid,
|
||||||
locationid: input.locationid ?? 0,
|
locationid: input.locationid ?? 0,
|
||||||
applocationid: input.applocationid ?? FIESTA_APPLOCATION_ID,
|
applocationid: input.applocationid ?? FIESTA_APPLOCATION_ID,
|
||||||
@@ -597,3 +861,56 @@ export interface UpdateUserInput {
|
|||||||
export async function updateUser(input: UpdateUserInput): Promise<Row> {
|
export async function updateUser(input: UpdateUserInput): Promise<Row> {
|
||||||
return fiestaSend<Row>('users/update', 'PUT', input);
|
return fiestaSend<Row>('users/update', 'PUT', input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateTenantInput {
|
||||||
|
tenantname: string;
|
||||||
|
companyname: string;
|
||||||
|
primarycontact: string;
|
||||||
|
primaryemail: string;
|
||||||
|
address?: string;
|
||||||
|
suburb?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
postcode?: string;
|
||||||
|
approved?: number;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /tenants/createtenantuser — Onboard a new tenant and create their admin user. */
|
||||||
|
export async function createTenantUser(input: CreateTenantInput): Promise<Row> {
|
||||||
|
const res = await fetch(`${FIESTA_MOB_BASE}/tenants/createtenantuser`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
const json = (await res.json().catch(() => null)) as { message?: string; status?: boolean } | null;
|
||||||
|
if (!res.ok || (json && json.status === false)) {
|
||||||
|
throw new Error(json?.message || `Tenant onboarding failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return json as Row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTenantLocationInput {
|
||||||
|
tenantid: number;
|
||||||
|
locationname: string;
|
||||||
|
address?: string;
|
||||||
|
suburb?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
postcode?: string;
|
||||||
|
contactno?: string;
|
||||||
|
email?: string;
|
||||||
|
opentime?: string;
|
||||||
|
closetime?: string;
|
||||||
|
deliverymins?: number;
|
||||||
|
deliveryradius?: number;
|
||||||
|
latitude?: string;
|
||||||
|
longitude?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /tenants/createtenantlocation — Create a new store location under a tenant. */
|
||||||
|
export async function createTenantLocation(input: CreateTenantLocationInput): Promise<Row> {
|
||||||
|
return fiestaSend<Row>('tenants/createtenantlocation', 'POST', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,3 +122,47 @@ export function deliveryRowToOrder(row: Row): CustomerOrder {
|
|||||||
locationid: num(row.locationid)
|
locationid: num(row.locationid)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* orders-board row (from /orders/getorders) -> CustomerOrder card.
|
||||||
|
* The orders API uses different field names than the deliveries board.
|
||||||
|
*/
|
||||||
|
export function orderRowToOrder(row: Row): CustomerOrder {
|
||||||
|
// Amount: orders API returns orderamount / ordervalue / collectionamt
|
||||||
|
const amount = num(row.ordervalue) || num(row.orderamount) || num(row.collectionamt);
|
||||||
|
// Rider: may come from the linked delivery record
|
||||||
|
const rider = str(row.ridername) || str(row.ridernames) || '';
|
||||||
|
// Customer: orders use different field names
|
||||||
|
const customerName =
|
||||||
|
str(row.deliverycustomer) ||
|
||||||
|
str(row.customername) ||
|
||||||
|
str(row.firstname) + (str(row.lastname) ? ` ${str(row.lastname)}` : '') ||
|
||||||
|
'Customer';
|
||||||
|
// Address: drop address (where order is delivered)
|
||||||
|
const address =
|
||||||
|
str(row.deliveryaddress) ||
|
||||||
|
str(row.deliverysuburb) ||
|
||||||
|
str(row.pickupaddress) ||
|
||||||
|
'Address unavailable';
|
||||||
|
// Hub: store that fulfilled the order
|
||||||
|
const hub =
|
||||||
|
str(row.locationname) ||
|
||||||
|
str(row.pickupcustomer) ||
|
||||||
|
str(row.tenantname) ||
|
||||||
|
`Location ${str(row.locationid)}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: str(row.orderid) || `ORD-${str(row.orderheaderid)}`,
|
||||||
|
customerName: customerName.trim() || 'Customer',
|
||||||
|
phone: str(row.contactno) || str(row.deliverycontactno) || '—',
|
||||||
|
address,
|
||||||
|
items: [],
|
||||||
|
amount,
|
||||||
|
time: shortTime(row.orderdate || row.deliverytime || row.createdat),
|
||||||
|
status: mapOrderStatus(str(row.orderstatus)),
|
||||||
|
assignedRider: rider || 'Pending Assignment',
|
||||||
|
hub,
|
||||||
|
itemCount: num(row.itemcount) || num(row.quantity),
|
||||||
|
locationid: num(row.locationid),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,12 +46,17 @@ import {
|
|||||||
getUserById,
|
getUserById,
|
||||||
createUser,
|
createUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
|
assignRiderToOrders,
|
||||||
CreateUserInput,
|
CreateUserInput,
|
||||||
|
createTenantUser,
|
||||||
|
createTenantLocation,
|
||||||
|
CreateTenantInput,
|
||||||
|
CreateTenantLocationInput,
|
||||||
} from './fiestaApi';
|
} from './fiestaApi';
|
||||||
|
|
||||||
export const fiestaKeys = {
|
export const fiestaKeys = {
|
||||||
orderSummary: (tenantid: number, fromdate: string, todate: string) =>
|
orderSummary: (tenantid: number, fromdate: string, todate: string, locationid?: number) =>
|
||||||
['fiesta', 'orderSummary', tenantid, fromdate, todate] as const,
|
['fiesta', 'orderSummary', tenantid, fromdate, todate, locationid ?? 0] as const,
|
||||||
locationSummary: (tenantid: number) => ['fiesta', 'locationSummary', tenantid] as const,
|
locationSummary: (tenantid: number) => ['fiesta', 'locationSummary', tenantid] as const,
|
||||||
orderInsight: (tenantid: number) => ['fiesta', 'orderInsight', tenantid] as const,
|
orderInsight: (tenantid: number) => ['fiesta', 'orderInsight', tenantid] as const,
|
||||||
orders: (params: Record<string, unknown>) => ['fiesta', 'orders', params] as const,
|
orders: (params: Record<string, unknown>) => ['fiesta', 'orders', params] as const,
|
||||||
@@ -60,7 +65,9 @@ export const fiestaKeys = {
|
|||||||
deliveryInsight: (tenantid: number) => ['fiesta', 'deliveryInsight', tenantid] as const,
|
deliveryInsight: (tenantid: number) => ['fiesta', 'deliveryInsight', tenantid] as const,
|
||||||
riders: (params: Record<string, unknown>) => ['fiesta', 'riders', params] as const,
|
riders: (params: Record<string, unknown>) => ['fiesta', 'riders', params] as const,
|
||||||
riderShifts: (applocationid: number) => ['fiesta', 'riderShifts', applocationid] as const,
|
riderShifts: (applocationid: number) => ['fiesta', 'riderShifts', applocationid] as const,
|
||||||
tenantLocations: (tenantid: number) => ['fiesta', 'tenantLocations', tenantid] as const,
|
// v2: bumped when test-row filtering was added to getTenantLocations so any
|
||||||
|
// warm cache holding the old unfiltered (duplicated/junk) rows is bypassed.
|
||||||
|
tenantLocations: (tenantid: number) => ['fiesta', 'tenantLocations', 'v2', tenantid] as const,
|
||||||
allTenants: (params: Record<string, unknown>) => ['fiesta', 'allTenants', params] as const,
|
allTenants: (params: Record<string, unknown>) => ['fiesta', 'allTenants', params] as const,
|
||||||
tenantCustomers: (params: Record<string, unknown>) => ['fiesta', 'tenantCustomers', params] as const,
|
tenantCustomers: (params: Record<string, unknown>) => ['fiesta', 'tenantCustomers', params] as const,
|
||||||
stockStatement: (params: Record<string, unknown>) => ['fiesta', 'stockStatement', params] as const,
|
stockStatement: (params: Record<string, unknown>) => ['fiesta', 'stockStatement', params] as const,
|
||||||
@@ -79,10 +86,10 @@ export const fiestaKeys = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Orders ──────────────────────────────────────────────────────────────────
|
// ── Orders ──────────────────────────────────────────────────────────────────
|
||||||
export function useFiestaOrderSummary(tenantid: number = FIESTA_TENANT_ID, fromdate: string, todate: string) {
|
export function useFiestaOrderSummary(tenantid: number = FIESTA_TENANT_ID, fromdate: string, todate: string, locationid?: number) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: fiestaKeys.orderSummary(tenantid, fromdate, todate),
|
queryKey: fiestaKeys.orderSummary(tenantid, fromdate, todate, locationid),
|
||||||
queryFn: () => getOrderSummary(tenantid, fromdate, todate),
|
queryFn: () => getOrderSummary(tenantid, fromdate, todate, locationid),
|
||||||
enabled: Boolean(tenantid && fromdate && todate),
|
enabled: Boolean(tenantid && fromdate && todate),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -108,6 +115,9 @@ export function useFiestaOrders(opts: {
|
|||||||
status: string;
|
status: string;
|
||||||
fromdate: string;
|
fromdate: string;
|
||||||
todate: string;
|
todate: string;
|
||||||
|
locationid?: number;
|
||||||
|
applocationid?: number;
|
||||||
|
keyword?: string;
|
||||||
pageno?: number;
|
pageno?: number;
|
||||||
pagesize?: number;
|
pagesize?: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -118,10 +128,61 @@ export function useFiestaOrders(opts: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches orders across all statuses for a given date range by firing one
|
||||||
|
* request per status in parallel and merging the results. This is needed
|
||||||
|
* because the /orders/getorders API requires an explicit status param and
|
||||||
|
* returns an empty array when status is blank or 'all'.
|
||||||
|
*/
|
||||||
|
export function useFiestaAllOrders(opts: {
|
||||||
|
tenantid: number;
|
||||||
|
fromdate: string;
|
||||||
|
todate: string;
|
||||||
|
locationid?: number;
|
||||||
|
applocationid?: number;
|
||||||
|
keyword?: string;
|
||||||
|
}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['fiesta', 'allOrders', opts],
|
||||||
|
queryFn: async () => {
|
||||||
|
const statuses = ['created', 'pending', 'processing', 'delivered', 'cancelled'];
|
||||||
|
const results = await Promise.all(
|
||||||
|
statuses.map(status =>
|
||||||
|
getOrders({
|
||||||
|
tenantid: opts.tenantid,
|
||||||
|
status,
|
||||||
|
fromdate: opts.fromdate,
|
||||||
|
todate: opts.todate,
|
||||||
|
locationid: opts.locationid,
|
||||||
|
applocationid: opts.applocationid,
|
||||||
|
keyword: opts.keyword,
|
||||||
|
pagesize: 100,
|
||||||
|
}).catch(() => [] as Row[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// Merge and deduplicate by orderid/orderheaderid
|
||||||
|
const merged: Row[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const list of results) {
|
||||||
|
for (const row of list) {
|
||||||
|
const id = String(row.orderid || row.orderheaderid || Math.random());
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
merged.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
},
|
||||||
|
enabled: Boolean(opts.tenantid && opts.fromdate && opts.todate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Deliveries ────────────────────────────────────────────────────────────────
|
// ── Deliveries ────────────────────────────────────────────────────────────────
|
||||||
export function useFiestaDeliverySummary(opts: {
|
export function useFiestaDeliverySummary(opts: {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
applocationid?: number;
|
applocationid?: number;
|
||||||
|
locationid?: number;
|
||||||
fromdate: string;
|
fromdate: string;
|
||||||
todate: string;
|
todate: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -132,7 +193,17 @@ export function useFiestaDeliverySummary(opts: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFiestaDeliveries(opts: { tenantid: number; fromdate: string; todate: string }) {
|
export function useFiestaDeliveries(opts: {
|
||||||
|
tenantid: number;
|
||||||
|
fromdate: string;
|
||||||
|
todate: string;
|
||||||
|
status?: string;
|
||||||
|
locationid?: number;
|
||||||
|
applocationid?: number;
|
||||||
|
keyword?: string;
|
||||||
|
pageno?: number;
|
||||||
|
pagesize?: number;
|
||||||
|
}) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: fiestaKeys.deliveries(opts),
|
queryKey: fiestaKeys.deliveries(opts),
|
||||||
queryFn: () => getDeliveries(opts),
|
queryFn: () => getDeliveries(opts),
|
||||||
@@ -148,8 +219,28 @@ export function useFiestaDeliveryInsight(tenantid: number = FIESTA_TENANT_ID) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-assign one rider to many orders (the Orders board's multi-select assign).
|
||||||
|
* Fires one updatedelivery per row in parallel, tolerates partial failure, and
|
||||||
|
* refreshes the orders + deliveries lists on completion.
|
||||||
|
*/
|
||||||
|
export function useFiestaAssignRider() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: { userid: number; orders: Row[] }) => assignRiderToOrders(input.userid, input.orders),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['fiesta', 'orders'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['fiesta', 'orderSummary'] });
|
||||||
|
// Refresh the Deliveries board AND its KPI summary so a freshly-assigned
|
||||||
|
// order shows up on the deliveries page immediately (table + count cards).
|
||||||
|
qc.invalidateQueries({ queryKey: ['fiesta', 'deliveries'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['fiesta', 'deliverySummary'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Partners / Riders ─────────────────────────────────────────────────────────
|
// ── Partners / Riders ─────────────────────────────────────────────────────────
|
||||||
export function useFiestaRiders(opts: { applocationid?: number; tenantid: number }) {
|
export function useFiestaRiders(opts: { applocationid?: number; tenantid: number; partnerid?: number }) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: fiestaKeys.riders(opts),
|
queryKey: fiestaKeys.riders(opts),
|
||||||
queryFn: () => getRiders(opts),
|
queryFn: () => getRiders(opts),
|
||||||
@@ -409,6 +500,28 @@ export function useFiestaUpdateUser() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create a new tenant and admin user, then refresh tenants list on success. */
|
||||||
|
export function useFiestaCreateTenant() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateTenantInput) => createTenantUser(input),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['fiesta', 'allTenants'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new tenant location, then refresh tenant locations list on success. */
|
||||||
|
export function useFiestaCreateLocation() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateTenantLocationInput) => createTenantLocation(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['fiesta', 'tenantLocations'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['fiesta', 'locationSummary'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Verify login credentials against the Fiesta web-login endpoint. A mutation
|
* Verify login credentials against the Fiesta web-login endpoint. A mutation
|
||||||
|
|||||||
81
src/services/storeCatalogue.ts
Normal file
81
src/services/storeCatalogue.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type MainSection = 'dashboard' | 'stores' | 'inventory' | 'orders' | 'users' | 'settings' | 'reports' | 'operations';
|
export type MainSection = 'dashboard' | 'stores' | 'inventory' | 'orders' | 'users' | 'settings' | 'reports' | 'operations' | 'admin-console';
|
||||||
|
|
||||||
export interface KPICardData {
|
export interface KPICardData {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user