console update
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Users, Search, Phone, Mail, ChevronRight, Award, MapPin } from 'lucide-react';
|
||||
import { useFiestaTenantCustomers, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||
import { num as fnum, str as fstr } from '../services/fiestaApi';
|
||||
import { num as fnum, str as fstr, customerName, customerStoreId } from '../services/fiestaApi';
|
||||
|
||||
interface AdminCustomersViewProps {
|
||||
tenantId: number;
|
||||
@@ -36,16 +36,19 @@ export default function AdminCustomersView({ tenantId }: AdminCustomersViewProps
|
||||
null;
|
||||
const spent = fnum(c.totalspent);
|
||||
|
||||
const locId = fnum(c.locationid) || fnum(c.applocationid);
|
||||
const loc = locations.find((l: any) => fnum(l.locationid) === locId || fnum(l.applocationid) === locId);
|
||||
const storeName = loc ? fstr(loc.locationname) : (fstr(c.locationname) || 'Central');
|
||||
// `locationid` is not a column on this response — the store link is
|
||||
// aliased `tenantlocationid`. Matching on the wrong name meant every
|
||||
// customer fell through to the invented store name 'Central'.
|
||||
const locId = customerStoreId(c);
|
||||
const loc = locations.find((l: any) => fnum(l.locationid) === locId);
|
||||
const storeName = (loc ? fstr(loc.locationname) : fstr(c.locationname)).trim() || '—';
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fstr(c.fullname) || `${fstr(c.firstname)} ${fstr(c.lastname)}`.trim() || 'Customer',
|
||||
name: customerName(c) || 'Customer',
|
||||
phone: fstr(c.contactno) || '—',
|
||||
email: fstr(c.email),
|
||||
address: fstr(c.address) || 'Coimbatore',
|
||||
address: fstr(c.address) || '—',
|
||||
ordersCount: Number(c.orderscount) || 0,
|
||||
totalSpent: spent > 0 ? `₹${spent.toLocaleString('en-IN')}` : '—',
|
||||
storeName,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useFiestaCustomerOrders } from '../services/fiestaQueries';
|
||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||
import { num as fnum, str as fstr, customerName, customerStoreId, type Row } from '../services/fiestaApi';
|
||||
import { Phone, MapPin, Mail, Receipt, X, Calendar, ShoppingBag, Wallet, TrendingUp, IndianRupee, Store } from 'lucide-react';
|
||||
import OrderDetailsModal from './OrderDetailsModal';
|
||||
import './CustomerDetailPanel.css';
|
||||
@@ -70,8 +70,12 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
|
||||
};
|
||||
}, [orders]);
|
||||
|
||||
const name = fstr(customer.customername) || fstr(customer.name) || 'Unknown Customer';
|
||||
// firstname/lastname are what the endpoint actually returns; `customername`
|
||||
// and `name` are not columns on this response, so the old read always produced
|
||||
// "Unknown Customer" (and "UC" initials) for every customer.
|
||||
const name = customerName(customer) || 'Unknown Customer';
|
||||
const phone = fstr(customer.contactno) || fstr(customer.phone) || '';
|
||||
const storeId = customerStoreId(customer);
|
||||
const email = fstr(customer.email) || '';
|
||||
const address = fstr(customer.address) || fstr(customer.deliveryaddress) || '';
|
||||
|
||||
@@ -113,10 +117,10 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
|
||||
<span className="cdp-contact-icon-bg bg-purple-100"><Store size={12} /></span>
|
||||
{fstr(customer.locationname) || fstr(customer.storename)}
|
||||
</div>
|
||||
) : fnum(customer.locationid) ? (
|
||||
) : storeId ? (
|
||||
<div className="cdp-contact-chip bg-purple-50 text-purple-700 border border-purple-100" style={{ cursor: 'default' }}>
|
||||
<span className="cdp-contact-icon-bg bg-purple-100"><Store size={12} /></span>
|
||||
Store {fnum(customer.locationid)}
|
||||
Store {storeId}
|
||||
</div>
|
||||
) : null}
|
||||
{phone && (
|
||||
|
||||
@@ -406,9 +406,9 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="bg-white max-h-[90vh] flex flex-col overflow-hidden 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, ${BRAND} 0%, ${BRAND_LIGHT} 100%)` }} />
|
||||
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Truck size={16} style={{ color: '#6366f1' }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}</h4>
|
||||
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Truck size={16} style={{ color: BRAND }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}</h4>
|
||||
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4 overflow-y-auto flex-1">
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Route, ShoppingBag, Truck, MapPin, Calendar, ChevronLeft, ChevronRight,
|
||||
import DispatchView from './DispatchView';
|
||||
import OrdersView from './OrdersView';
|
||||
import DeliveriesView from './DeliveriesView';
|
||||
import { useFiestaDeliveries } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, ymd, num as fnum } from '../services/fiestaApi';
|
||||
import { useFiestaDeliveries, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, ymd, num as fnum, str as fstr } from '../services/fiestaApi';
|
||||
import type { Row } from '../services/fiestaApi';
|
||||
import './DispatchView.css'; // For #hdr and date-chip styles
|
||||
|
||||
@@ -34,6 +34,26 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
|
||||
const rows = useMemo(() => allRows.filter(inScope), [allRows, locationid]);
|
||||
const totalOrders = rows.length;
|
||||
|
||||
// Operating area for the header pill. This was the hardcoded string
|
||||
// "Coimbatore", shown to every tenant on the platform regardless of where they
|
||||
// actually trade. The locations query is already cached by DispatchView (same
|
||||
// key), so reading it here costs no extra request.
|
||||
const locationsQ = useFiestaTenantLocations(tenantId);
|
||||
const scopeLabel = useMemo(() => {
|
||||
const locs = locationsQ.data ?? [];
|
||||
if (locs.length === 0) return '';
|
||||
if (locationid) {
|
||||
// Store user — name the outlet's own city.
|
||||
const mine = locs.find((l) => fnum(l.locationid) === locationid);
|
||||
return fstr(mine?.city).trim() || fstr(mine?.state).trim();
|
||||
}
|
||||
// Admin — one city if the whole tenant sits in one, otherwise a count.
|
||||
const cities = [...new Set(locs.map((l) => fstr(l.city).trim()).filter(Boolean))];
|
||||
if (cities.length === 1) return cities[0];
|
||||
if (cities.length > 1) return `${cities.length} cities`;
|
||||
return '';
|
||||
}, [locationsQ.data, locationid]);
|
||||
|
||||
const isToday = date === ymd(today);
|
||||
const dateObj = new Date(`${date}T00:00:00`);
|
||||
const prettyDate = `${WEEKDAYS[dateObj.getDay()]}, ${dateObj.getDate()} ${MONTHS[dateObj.getMonth()]}`;
|
||||
@@ -53,12 +73,14 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center font-extrabold text-xs sm:text-sm text-white shadow-sm">C</div>
|
||||
<div className="text-base sm:text-lg font-extrabold text-slate-800 tracking-tight">Console</div>
|
||||
<div className="relative inline-block ml-1">
|
||||
<span className="flex items-center gap-1 px-2 sm:px-2.5 py-0.5 sm:py-1 bg-purple-50 text-purple-700 border border-purple-200/60 rounded-full text-[10px] sm:text-[11px] font-bold cursor-default shadow-sm">
|
||||
<MapPin size={12} className="text-purple-500" />
|
||||
<span className="max-w-[120px] sm:max-w-[180px] truncate">Coimbatore</span>
|
||||
</span>
|
||||
</div>
|
||||
{scopeLabel && (
|
||||
<div className="relative inline-block ml-1">
|
||||
<span className="flex items-center gap-1 px-2 sm:px-2.5 py-0.5 sm:py-1 bg-purple-50 text-purple-700 border border-purple-200/60 rounded-full text-[10px] sm:text-[11px] font-bold cursor-default shadow-sm">
|
||||
<MapPin size={12} className="text-purple-500" />
|
||||
<span className="max-w-[120px] sm:max-w-[180px] truncate">{scopeLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +99,7 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-semibold text-emerald-600 bg-emerald-50 px-2.5 sm:px-3 py-1 sm:py-1.5 rounded-full border border-emerald-100">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" /> Live · {totalOrders} orders
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" /> Live · {totalOrders} {totalOrders === 1 ? 'delivery' : 'deliveries'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -140,7 +162,9 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sub-tabs for Map View */}
|
||||
{/* Sub-tabs for Map View — only meaningful on the Map tab; they used to
|
||||
stay visible (and inert) on Orders and Deliveries. */}
|
||||
{activeTab === 'map' && (
|
||||
<div className="flex items-center p-1 ml-3 sm:ml-4 bg-slate-100/80 rounded-lg border border-slate-200/80 shadow-inner shrink-0">
|
||||
<button
|
||||
onClick={() => setMapViewMode('stores')}
|
||||
@@ -160,6 +184,7 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
|
||||
<Users size={13} className={mapViewMode === 'customers' ? 'text-blue-500' : 'text-slate-400'} /> By Customer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2555,6 +2555,32 @@
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Idle outlet — listed so the admin can see every store under the tenant, but
|
||||
visually recessed so the stores actually dispatching today read first. Still
|
||||
clickable: the focused view explains that nothing has gone out. */
|
||||
.dispatch-container .rcard.zone-card.is-idle {
|
||||
background: #fcfcfd;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dispatch-container .rcard.zone-card.is-idle::before {
|
||||
background: linear-gradient(180deg, #cbd5e1, #94a3b8);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.dispatch-container .rcard.zone-card.is-idle .zone-card-name {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dispatch-container .rcard.zone-card.is-idle .zone-card-emoji {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
border-color: rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
.dispatch-container .rcard.zone-card.is-idle .zone-card-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Progress row: status bar + delivered/total counter */
|
||||
.dispatch-container .zone-progress-row {
|
||||
display: flex;
|
||||
|
||||
@@ -22,10 +22,9 @@ import {
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Bike,
|
||||
Store,
|
||||
Users,
|
||||
Phone,
|
||||
ShoppingBag,
|
||||
Store,
|
||||
Truck,
|
||||
Package,
|
||||
Ruler,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
Mailbox,
|
||||
StickyNote,
|
||||
ArrowLeftRight,
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
List,
|
||||
@@ -44,20 +42,19 @@ import {
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useFiestaDeliveries,
|
||||
useFiestaRiders,
|
||||
useFiestaRiderPeriodicLogs,
|
||||
useFiestaTenantLocations,
|
||||
useFiestaTenantCustomers,
|
||||
} from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||
import {
|
||||
colorFor,
|
||||
getStatusStyle,
|
||||
STATUS_STYLES,
|
||||
extractTimeOnly,
|
||||
} from '../services/dispatchShared';
|
||||
FIESTA_TENANT_ID,
|
||||
num as fnum,
|
||||
str as fstr,
|
||||
customerName,
|
||||
customerStoreId,
|
||||
type Row,
|
||||
} from '../services/fiestaApi';
|
||||
import { colorFor } from '../services/dispatchShared';
|
||||
import DispatchMap, { type MapPoint } from './DispatchMap';
|
||||
import RiderTelemetryPanel from './RiderTelemetryPanel';
|
||||
import CustomerDetailPanel from './CustomerDetailPanel';
|
||||
import './DispatchView.css';
|
||||
|
||||
@@ -78,6 +75,25 @@ function statusStyle(s: string): React.CSSProperties {
|
||||
return { background: `${hex}1f`, color: hex };
|
||||
}
|
||||
|
||||
/**
|
||||
* Best available area label for a delivery row. `getdeliveries` has NO
|
||||
* `deliverysuburb` column — reading it left every "areas" count at 0 and hid the
|
||||
* suburb strip entirely. The feed does carry `locationsuburb` and a full
|
||||
* `deliveryaddress`, so fall back to the address's locality segment
|
||||
* ("12 Main St, Peelamedu, Coimbatore, 641004" → "Coimbatore").
|
||||
*/
|
||||
function areaOf(r: Row): string {
|
||||
const direct = fstr(r.deliverysuburb).trim() || fstr(r.locationsuburb).trim();
|
||||
if (direct) return direct;
|
||||
const parts = fstr(r.deliveryaddress)
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
// Trailing postcode / country segments aren't areas.
|
||||
.filter((p) => !/^\d{4,6}$/.test(p) && p.toLowerCase() !== 'india');
|
||||
return parts.length > 1 ? parts[parts.length - 1] : '';
|
||||
}
|
||||
|
||||
/** Drop coordinates from a delivery row (several field spellings), or null. */
|
||||
function dropLatLon(r: Row): [number, number] | null {
|
||||
const lat = fnum(r.droplat) || fnum(r.deliverylat) || fnum(r.deliverylatitude);
|
||||
@@ -92,12 +108,9 @@ function pickupLatLon(r: Row): [number, number] | null {
|
||||
return lat && lon ? [lat, lon] : null;
|
||||
}
|
||||
|
||||
// ── View modes (match #strat-row tabs) ───────────────────────────────────────────
|
||||
type ViewMode = 'stores' | 'zones' | 'customers' | 'riders' | 'kitchens';
|
||||
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [
|
||||
{ id: 'stores', label: 'By Store', icon: Store },
|
||||
{ id: 'customers', label: 'By Customer', icon: Users },
|
||||
];
|
||||
// ── View modes ──────────────────────────────────────────────────────────────────
|
||||
// The tab row lives in DispatchHubView; only these two modes are reachable.
|
||||
type ViewMode = 'stores' | 'customers';
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
@@ -111,21 +124,19 @@ interface Group {
|
||||
suburbs: Map<string, number>;
|
||||
statusCounts: Record<string, number>;
|
||||
raw?: any;
|
||||
/** Customer view: the outlet this customer is registered against, for the card badge. */
|
||||
storeName?: string;
|
||||
}
|
||||
|
||||
interface DispatchViewProps {
|
||||
locationid?: number;
|
||||
tenantId?: number;
|
||||
date: string;
|
||||
viewMode: 'stores' | 'zones' | 'customers' | 'riders' | 'kitchens';
|
||||
viewMode: ViewMode;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID, date, viewMode }: DispatchViewProps) {
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const [focusedRiderId, setFocusedRiderId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFocusedId(null);
|
||||
@@ -140,16 +151,10 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
// Core dispatch data
|
||||
const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid });
|
||||
const locationsQ = useFiestaTenantLocations(tenantId);
|
||||
// Customers. Admin (no locationid) gets every customer under the tenant and
|
||||
// filters client-side; a store user sends its locationid so the backend scopes
|
||||
// to that outlet server-side via `tenantcustomers.locationid`.
|
||||
const customersQ = useFiestaTenantCustomers({ tenantid: tenantId, locationid: locationid || 0 });
|
||||
const ridersQ = useFiestaRiders({ tenantid: tenantId });
|
||||
|
||||
// Rider periodic logs (GPS snapshots) for the focused rider
|
||||
const riderLogsQ = useFiestaRiderPeriodicLogs({
|
||||
userid: focusedRiderId ?? undefined,
|
||||
fromdate: date,
|
||||
todate: date,
|
||||
tenantid: tenantId,
|
||||
});
|
||||
|
||||
// Live deliveries only — no sample/demo fallback. When the feed is empty the
|
||||
// cockpit shows a genuine empty state rather than fabricated riders/stops.
|
||||
@@ -162,55 +167,94 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
[allRows, locationid],
|
||||
);
|
||||
|
||||
/** locationid → outlet name, for stamping a store badge on customer cards. */
|
||||
const storeNames = useMemo(() => {
|
||||
const m = new Map<number, string>();
|
||||
for (const loc of locationsQ.data ?? []) {
|
||||
const id = fnum(loc.locationid);
|
||||
if (id) m.set(id, fstr(loc.locationname).trim() || `Store ${id}`);
|
||||
}
|
||||
return m;
|
||||
}, [locationsQ.data]);
|
||||
|
||||
// ── Grouping ────────────────────────────────────────────────────────────────
|
||||
// Both modes seed from the roster first (every outlet / every customer under
|
||||
// the scope) and then fold the day's deliveries into those seeds. That's what
|
||||
// makes an outlet with no deliveries today, or a customer who hasn't ordered
|
||||
// today, still appear in the list rather than vanishing.
|
||||
const groups = useMemo<Group[]>(() => {
|
||||
const map = new Map<string, Group>();
|
||||
|
||||
const blank = (id: string, name: string): Group => ({
|
||||
id,
|
||||
name,
|
||||
color: colorFor(id),
|
||||
orders: [],
|
||||
delivered: 0,
|
||||
totalKm: 0,
|
||||
profit: 0,
|
||||
riders: new Set(),
|
||||
suburbs: new Map(),
|
||||
statusCounts: {},
|
||||
});
|
||||
|
||||
if (viewMode === 'stores' && locationsQ.data) {
|
||||
// Admin: every outlet under the tenant. Store user: only its own, because
|
||||
// `locationid` is set.
|
||||
for (const loc of locationsQ.data) {
|
||||
if (locationid && fnum(loc.locationid) !== locationid) continue;
|
||||
const id = String(fnum(loc.locationid)).toLowerCase();
|
||||
const name = fstr(loc.locationname) || `Store ${id}`;
|
||||
map.set(id, { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {} });
|
||||
map.set(id, blank(id, fstr(loc.locationname) || `Store ${id}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (viewMode === 'customers' && customersQ.data) {
|
||||
for (const cust of customersQ.data) {
|
||||
if (customerStoreFilter !== 'all') {
|
||||
const locId = String(fnum(cust.locationid));
|
||||
if (locId !== customerStoreFilter) continue;
|
||||
}
|
||||
const storeId = customerStoreId(cust);
|
||||
// Admin's store dropdown. A store user never sees it (the backend has
|
||||
// already scoped the list), so this only ever narrows the admin view.
|
||||
if (customerStoreFilter !== 'all' && String(storeId) !== customerStoreFilter) continue;
|
||||
const id = String(fnum(cust.customerid) || fstr(cust.contactno)).toLowerCase();
|
||||
const name = fstr(cust.customername) || fstr(cust.name) || `Customer ${id}`;
|
||||
map.set(id, { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {}, raw: cust });
|
||||
map.set(id, {
|
||||
...blank(id, customerName(cust) || `Customer ${id}`),
|
||||
raw: cust,
|
||||
storeName: storeNames.get(storeId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const titleCase = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
||||
const keyOf = (r: Row): { id: string; name: string } => {
|
||||
if (viewMode === 'riders') {
|
||||
const id = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
|
||||
return { id, name: fstr(r.ridername) || fstr(r.username) || (id === 'unassigned' ? 'Unassigned' : `Rider ${id}`) };
|
||||
}
|
||||
if (viewMode === 'stores') {
|
||||
const locId = fstr(r.locationid) || fstr(r.pickuplocationid) || 'unknown';
|
||||
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || `Store ${locId}`;
|
||||
return { id: locId.toLowerCase(), name };
|
||||
}
|
||||
if (viewMode === 'customers') {
|
||||
const custId = fstr(r.customerid) || fstr(r.contactno) || fstr(r.deliverycustomerphone) || 'unknown';
|
||||
const name = fstr(r.deliverycustomer) || fstr(r.customername) || `Customer ${custId}`;
|
||||
return { id: custId.toLowerCase(), name };
|
||||
}
|
||||
const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
|
||||
return { id: name.toLowerCase(), name };
|
||||
const custId = fstr(r.customerid) || fstr(r.deliverycontactno) || 'unknown';
|
||||
return { id: custId.toLowerCase(), name: customerName(r) || `Customer ${custId}` };
|
||||
};
|
||||
|
||||
for (const r of rows) {
|
||||
const { id, name } = keyOf(r);
|
||||
let g = map.get(id);
|
||||
if (!g) {
|
||||
g = { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {} };
|
||||
// A delivery whose customer isn't in the roster (not linked to this
|
||||
// tenant, or beyond the page we fetched). Keep it — dropping it would
|
||||
// hide real work from the board — but honour an active store filter.
|
||||
if (viewMode === 'customers' && customerStoreFilter !== 'all') continue;
|
||||
g = blank(id, name);
|
||||
if (viewMode === 'customers') {
|
||||
// Stand in for the missing customer record so the card still opens.
|
||||
// Without a `raw` the detail panel stayed on "Select a customer" no
|
||||
// matter how many times the card was clicked.
|
||||
g.raw = {
|
||||
customerid: fnum(r.customerid),
|
||||
firstname: fstr(r.deliverycustomer),
|
||||
contactno: fstr(r.deliverycontactno),
|
||||
address: fstr(r.deliveryaddress),
|
||||
tenantlocationid: fnum(r.locationid),
|
||||
locationname: fstr(r.locationname),
|
||||
};
|
||||
g.storeName = storeNames.get(fnum(r.locationid)) || fstr(r.locationname) || undefined;
|
||||
}
|
||||
map.set(id, g);
|
||||
}
|
||||
g.orders.push(r);
|
||||
@@ -221,11 +265,11 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
g.profit += fnum(r.profit);
|
||||
const rid = fstr(r.userid) || fstr(r.ridername);
|
||||
if (rid) g.riders.add(rid);
|
||||
const sub = fstr(r.deliverysuburb);
|
||||
const sub = areaOf(r);
|
||||
if (sub) g.suburbs.set(sub, (g.suburbs.get(sub) ?? 0) + 1);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => b.orders.length - a.orders.length || a.name.localeCompare(b.name));
|
||||
}, [rows, viewMode, locationsQ.data, customersQ.data]);
|
||||
}, [rows, viewMode, locationid, locationsQ.data, customersQ.data, customerStoreFilter, storeNames]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'stores' && locationid && groups.length === 1 && !focusedId) {
|
||||
@@ -234,28 +278,16 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
}, [viewMode, locationid, groups, focusedId]);
|
||||
|
||||
const focused = groups.find((g) => g.id === focusedId) ?? null;
|
||||
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, one per rider.
|
||||
const tripBlocks = useMemo(() => {
|
||||
if (!focused) return [];
|
||||
const map = new Map<string, { label: string; color: string; orders: Row[] }>();
|
||||
for (const r of focused.orders) {
|
||||
let key: string;
|
||||
let label: string;
|
||||
let color: string;
|
||||
if (groupedByRider) {
|
||||
const rid = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
|
||||
key = rid;
|
||||
label = fstr(r.ridername) || fstr(r.username) || (rid === 'unassigned' ? 'Unassigned' : `Rider ${rid}`);
|
||||
color = colorFor(rid);
|
||||
} else {
|
||||
key = fstr(r.trip_number) || '1';
|
||||
label = `Trip ${key}`;
|
||||
color = focused.color;
|
||||
}
|
||||
let blk = map.get(key);
|
||||
if (!blk) { blk = { label, color, orders: [] }; map.set(key, blk); }
|
||||
const rid = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
|
||||
const label = fstr(r.ridername) || (rid === 'unassigned' ? 'Unassigned' : `Rider ${rid}`);
|
||||
let blk = map.get(rid);
|
||||
if (!blk) { blk = { label, color: colorFor(rid), orders: [] }; map.set(rid, blk); }
|
||||
blk.orders.push(r);
|
||||
}
|
||||
const blocks = Array.from(map.values());
|
||||
@@ -266,6 +298,8 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
const tb = fstr(b.deliverytime) || fstr(b.expecteddeliverytime);
|
||||
return ta.localeCompare(tb);
|
||||
}
|
||||
// `step` isn't in the getdeliveries response, so planned order falls back
|
||||
// to assignment time. Kept in case the backend starts sending it.
|
||||
const sa = fnum(a.step);
|
||||
const sb = fnum(b.step);
|
||||
if (sa && sb && sa !== sb) return sa - sb;
|
||||
@@ -273,7 +307,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}, [focused, groupedByRider, tripSort]);
|
||||
}, [focused, tripSort]);
|
||||
|
||||
// Map points: the focused group's ordered stops (with a route), else every stop
|
||||
// for the day (coloured per rider). Rows without coordinates are skipped.
|
||||
@@ -289,8 +323,8 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
lon: ll[1],
|
||||
step: fnum(r.step) || i + 1,
|
||||
color: focused ? focused.color : colorFor(fstr(r.userid) || fstr(r.ridername) || 'x'),
|
||||
title: fstr(r.deliverycustomer) || `Order ${fstr(r.orderid)}`,
|
||||
subtitle: fstr(r.deliverysuburb) || fstr(r.deliveryaddress),
|
||||
title: customerName(r) || `Order ${fstr(r.orderid)}`,
|
||||
subtitle: areaOf(r) || fstr(r.deliveryaddress),
|
||||
status: fstr(r.orderstatus),
|
||||
raw: r,
|
||||
});
|
||||
@@ -302,11 +336,14 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
const firstOrder = tripBlocks[0]?.orders[0] ?? focused?.orders[0];
|
||||
const routeStart = focused && firstOrder ? pickupLatLon(firstOrder) : null;
|
||||
|
||||
// The roster query matters as much as the delivery feed — gating on deliveries
|
||||
// alone flashed "No customers found" while the customer list was still loading.
|
||||
const isLoading =
|
||||
deliveriesQ.isLoading || (viewMode === 'customers' ? customersQ.isLoading : locationsQ.isLoading);
|
||||
|
||||
// KPI scope.
|
||||
const totalOrders = rows.length;
|
||||
const totalDelivered = rows.filter((r) => fstr(r.orderstatus).toLowerCase() === 'delivered').length;
|
||||
const activeRiders = new Set(rows.map((r) => fstr(r.userid) || fstr(r.ridername)).filter(Boolean)).size;
|
||||
const fleetSize = (ridersQ.data ?? []).length;
|
||||
|
||||
const fmtTime = (raw: unknown): string => {
|
||||
const m = fstr(raw).match(/(\d{1,2}):(\d{2})/);
|
||||
@@ -364,26 +401,27 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
</div>
|
||||
|
||||
<div id="riders-panel">
|
||||
{deliveriesQ.isLoading ? (
|
||||
<div className="ph">Loading dispatch feed…</div>
|
||||
{isLoading ? (
|
||||
<div className="ph">{viewMode === 'customers' ? 'Loading customers…' : 'Loading dispatch feed…'}</div>
|
||||
) : focused && viewMode !== 'customers' ? (
|
||||
<FocusedDetail
|
||||
focused={focused}
|
||||
tripBlocks={tripBlocks}
|
||||
groupedByRider={groupedByRider}
|
||||
tripSort={tripSort}
|
||||
setTripSort={setTripSort}
|
||||
onBack={(locationid && viewMode === 'stores' && groups.length === 1) ? undefined : () => setFocusedId(null)}
|
||||
fmtTime={fmtTime}
|
||||
riderLogs={riderLogsQ.data}
|
||||
riderLogsLoading={riderLogsQ.isLoading}
|
||||
/>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="ph">{viewMode === 'customers' ? 'No customers found' : 'No deliveries for this day'}</div>
|
||||
<div className="ph">
|
||||
{viewMode === 'customers'
|
||||
? (customerStoreFilter !== 'all' ? 'No customers for this store' : 'No customers found')
|
||||
: 'No stores found'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ph relative flex items-center justify-between w-full h-8">
|
||||
<span>{viewMode === 'riders' ? 'Riders' : viewMode === 'customers' ? 'Customers' : viewMode === 'stores' ? 'Stores' : 'Zones'} ({groups.length})</span>
|
||||
<span>{viewMode === 'customers' ? 'Customers' : 'Stores'} ({groups.length})</span>
|
||||
{viewMode === 'customers' && !locationid && locationsQ.data && (
|
||||
<div className="absolute right-0 flex items-center group/filter cursor-pointer">
|
||||
<select
|
||||
@@ -408,30 +446,15 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{groups.map((g) => {
|
||||
const isSelected = focusedId === g.id;
|
||||
return (
|
||||
<React.Fragment key={g.id}>
|
||||
{viewMode === 'customers' ? (
|
||||
<CustomerCard g={g} onClick={() => setFocusedId(g.id)} isSelected={isSelected} />
|
||||
) : viewMode === 'riders' ? (
|
||||
<RiderCard
|
||||
g={g}
|
||||
onClick={() => {
|
||||
setFocusedId(g.id);
|
||||
const rid = fnum(g.orders[0]?.userid);
|
||||
if (rid) setFocusedRiderId(rid);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ZoneCard g={g} onClick={() => {
|
||||
setFocusedId(g.id);
|
||||
setFocusedRiderId(null);
|
||||
}} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{groups.map((g) => (
|
||||
<React.Fragment key={g.id}>
|
||||
{viewMode === 'customers' ? (
|
||||
<CustomerCard g={g} onClick={() => setFocusedId(g.id)} isSelected={focusedId === g.id} />
|
||||
) : (
|
||||
<StoreCard g={g} onClick={() => setFocusedId(g.id)} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -480,7 +503,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
</div>
|
||||
) : !focused ? (
|
||||
<div className="dmp-overlay-note">
|
||||
<MapIcon size={13} /> Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : viewMode === 'riders' ? 'rider' : 'group'} to draw its route.
|
||||
<MapIcon size={13} /> Select a store to draw its route.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -511,88 +534,70 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Rider card ───────────────────────────────────────────────────────────────────
|
||||
function RiderCard({ g, onClick }: { g: Group; onClick: () => void }) {
|
||||
const total = g.orders.length;
|
||||
const percent = total ? Math.round((g.delivered / total) * 100) : 0;
|
||||
const isDone = total > 0 && g.delivered === total;
|
||||
const zoneName = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || 'Mixed';
|
||||
const trips = new Set(g.orders.map((o) => fstr(o.trip_number) || '1')).size;
|
||||
return (
|
||||
<div className="rcard" onClick={onClick}>
|
||||
<div className="rcard-top">
|
||||
<div className="rcard-emo" style={{ background: `${g.color}18`, color: g.color }}>
|
||||
<Bike size={18} />
|
||||
</div>
|
||||
<div className="rcard-info">
|
||||
<div className="rcard-name">{g.name}</div>
|
||||
<div className="rcard-zone">{zoneName} · {trips} trip{trips > 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<div className={`rcard-badge ${isDone ? 'is-done' : ''}`}>{g.delivered}/{total}</div>
|
||||
</div>
|
||||
<div className="bar-bg">
|
||||
<div className="bar-fg" style={{ width: `${percent}%`, background: g.color }} />
|
||||
</div>
|
||||
<div className="rcard-meta">
|
||||
<span><Ruler size={11} /> {g.totalKm.toFixed(1)} km</span>
|
||||
{g.profit > 0 && <span><Wallet size={11} /> ₹{g.profit.toLocaleString('en-IN')}</span>}
|
||||
</div>
|
||||
<div className="step-ids">
|
||||
{g.orders.slice(0, 16).map((o, i) => (
|
||||
<span key={fstr(o.orderid) || i} className="step-id">S{fnum(o.step) || i + 1}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Zone card (also used for By Location / All Routes) ───────────────────────────
|
||||
function ZoneCard({ g, onClick }: { g: Group; onClick: () => void }) {
|
||||
// ── Store card ───────────────────────────────────────────────────────────────────
|
||||
// One per outlet. Admin sees every outlet under the tenant, a store user sees
|
||||
// only its own. Outlets with no deliveries today are deliberately still listed —
|
||||
// "which of my stores is idle" is the question the admin list has to answer — so
|
||||
// they render a quiet idle state instead of a row of zeroes.
|
||||
function StoreCard({ g, onClick }: { g: Group; onClick: () => void }) {
|
||||
const suburbs = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1]).map(([s]) => s);
|
||||
const idle = g.orders.length === 0;
|
||||
return (
|
||||
<div className="rcard zone-card" onClick={onClick}>
|
||||
<div className={`rcard zone-card${idle ? ' is-idle' : ''}`} onClick={onClick}>
|
||||
<div className="zone-card-header">
|
||||
<div className="zone-card-emoji" style={{ color: g.color }}><MapIcon size={16} /></div>
|
||||
<div className="zone-card-emoji" style={{ color: idle ? '#94a3b8' : g.color }}><Store size={16} /></div>
|
||||
<div className="zone-card-titles">
|
||||
<div className="zone-card-name">{g.name}</div>
|
||||
<div className="zone-card-sub">{g.riders.size} rider{g.riders.size === 1 ? '' : 's'} · {g.orders.length} orders</div>
|
||||
<div className="zone-card-sub">
|
||||
{idle
|
||||
? 'No deliveries today'
|
||||
: `${g.riders.size} rider${g.riders.size === 1 ? '' : 's'} · ${g.orders.length} ${g.orders.length === 1 ? 'delivery' : 'deliveries'}`}
|
||||
</div>
|
||||
</div>
|
||||
<span className="zone-card-arrow" aria-hidden="true">→</span>
|
||||
</div>
|
||||
{g.orders.length > 0 && (
|
||||
<div className="zone-progress-row">
|
||||
<div className="zone-status-bar">
|
||||
{Object.entries(g.statusCounts).map(([s, c]) => (
|
||||
<div key={s} className="zone-status-seg" style={{ flex: c, background: STATUS_HEX[s] || '#cbd5e1' }} title={`${s}: ${c}`} />
|
||||
))}
|
||||
{!idle && (
|
||||
<>
|
||||
<div className="zone-progress-row">
|
||||
<div className="zone-status-bar">
|
||||
{Object.entries(g.statusCounts).map(([s, c]) => (
|
||||
<div key={s} className="zone-status-seg" style={{ flex: c, background: STATUS_HEX[s] || '#cbd5e1' }} title={`${s}: ${c}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="zone-progress-label">{g.delivered}/{g.orders.length}</div>
|
||||
</div>
|
||||
<div className="zone-progress-label">{g.delivered}/{g.orders.length}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="zone-stat-pills">
|
||||
<span className="zone-stat-pill">
|
||||
<span className="zone-stat-icon"><MapPin size={12} /></span>
|
||||
<span className="zone-stat-value">{g.suburbs.size}</span>
|
||||
<span className="zone-stat-label">areas</span>
|
||||
</span>
|
||||
<span className="zone-stat-pill">
|
||||
<span className="zone-stat-icon"><Ruler size={12} /></span>
|
||||
<span className="zone-stat-value">{g.totalKm.toFixed(0)}</span>
|
||||
<span className="zone-stat-label">km</span>
|
||||
</span>
|
||||
{g.profit > 0 && (
|
||||
<span className="zone-stat-pill">
|
||||
<span className="zone-stat-icon"><Wallet size={12} /></span>
|
||||
<span className="zone-stat-value">₹{g.profit.toLocaleString('en-IN')}</span>
|
||||
<span className="zone-stat-label">profit</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{suburbs.length > 0 && (
|
||||
<div className="zone-card-suburbs">
|
||||
<span className="zone-card-suburbs-text">{suburbs.slice(0, 3).join(' · ')}</span>
|
||||
{suburbs.length > 3 && <span className="zone-card-suburbs-more">+{suburbs.length - 3}</span>}
|
||||
</div>
|
||||
<div className="zone-stat-pills">
|
||||
{/* Only render a stat the feed can actually populate — the areas and
|
||||
profit pills used to sit at a permanent 0 for every store. */}
|
||||
{g.suburbs.size > 0 && (
|
||||
<span className="zone-stat-pill">
|
||||
<span className="zone-stat-icon"><MapPin size={12} /></span>
|
||||
<span className="zone-stat-value">{g.suburbs.size}</span>
|
||||
<span className="zone-stat-label">{g.suburbs.size === 1 ? 'area' : 'areas'}</span>
|
||||
</span>
|
||||
)}
|
||||
{g.totalKm > 0 && (
|
||||
<span className="zone-stat-pill">
|
||||
<span className="zone-stat-icon"><Ruler size={12} /></span>
|
||||
<span className="zone-stat-value">{g.totalKm.toFixed(0)}</span>
|
||||
<span className="zone-stat-label">km</span>
|
||||
</span>
|
||||
)}
|
||||
{g.profit > 0 && (
|
||||
<span className="zone-stat-pill">
|
||||
<span className="zone-stat-icon"><Wallet size={12} /></span>
|
||||
<span className="zone-stat-value">₹{g.profit.toLocaleString('en-IN')}</span>
|
||||
<span className="zone-stat-label">profit</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{suburbs.length > 0 && (
|
||||
<div className="zone-card-suburbs">
|
||||
<span className="zone-card-suburbs-text">{suburbs.slice(0, 3).join(' · ')}</span>
|
||||
{suburbs.length > 3 && <span className="zone-card-suburbs-more">+{suburbs.length - 3}</span>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -602,23 +607,17 @@ function ZoneCard({ g, onClick }: { g: Group; onClick: () => void }) {
|
||||
function FocusedDetail({
|
||||
focused,
|
||||
tripBlocks,
|
||||
groupedByRider,
|
||||
tripSort,
|
||||
setTripSort,
|
||||
onBack,
|
||||
fmtTime,
|
||||
riderLogs,
|
||||
riderLogsLoading,
|
||||
}: {
|
||||
focused: Group;
|
||||
tripBlocks: Array<{ label: string; color: string; orders: Row[] }>;
|
||||
groupedByRider: boolean;
|
||||
tripSort: 'planned' | 'time';
|
||||
setTripSort: (v: 'planned' | 'time') => void;
|
||||
onBack?: () => void;
|
||||
fmtTime: (raw: unknown) => string;
|
||||
riderLogs?: Row[];
|
||||
riderLogsLoading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -628,21 +627,13 @@ function FocusedDetail({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{riderLogs && riderLogs.length > 0 && (
|
||||
<RiderTelemetryPanel
|
||||
logs={riderLogs}
|
||||
riderName={focused.name}
|
||||
isLoading={riderLogsLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tripBlocks.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center h-48 border-2 border-dashed border-slate-200 rounded-xl mt-4 bg-slate-50/50">
|
||||
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center text-slate-400 mb-3">
|
||||
<Package size={20} />
|
||||
</div>
|
||||
<p className="font-bold text-slate-700 text-sm">No orders to display</p>
|
||||
<p className="text-xs text-slate-500 mt-1">There are no deliveries matching this selection for the current date.</p>
|
||||
<p className="font-bold text-slate-700 text-sm">No deliveries for {focused.name}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Nothing has been dispatched from this store on the selected date.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -679,7 +670,7 @@ function FocusedDetail({
|
||||
<div className="zone-order-num" style={{ background: `${blk.color}15`, color: blk.color }}>{step}</div>
|
||||
<div className="zone-order-id-block">
|
||||
<div className="zone-order-id">Order #{fstr(o.orderid) || fstr(o.deliveryid)}</div>
|
||||
{groupedByRider && fstr(o.ridername) && (
|
||||
{fstr(o.ridername) && (
|
||||
<div className="zone-order-rider"><Bike size={10} /> {fstr(o.ridername)}</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -693,12 +684,12 @@ function FocusedDetail({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="zone-order-customer"><Mailbox size={11} /> {fstr(o.deliverycustomer) || 'Customer'}</div>
|
||||
<div className="zone-order-customer"><Mailbox size={11} /> {customerName(o) || 'Customer'}</div>
|
||||
{fstr(o.pickupcustomer) && (
|
||||
<div className="zone-order-line"><Utensils size={11} /> {fstr(o.pickupcustomer)}</div>
|
||||
)}
|
||||
{(fstr(o.deliverysuburb) || fstr(o.deliveryaddress)) && (
|
||||
<div className="zone-order-line"><MapPin size={11} /> {fstr(o.deliverysuburb) || fstr(o.deliveryaddress)}</div>
|
||||
{fstr(o.deliveryaddress) && (
|
||||
<div className="zone-order-line"><MapPin size={11} /> {fstr(o.deliveryaddress)}</div>
|
||||
)}
|
||||
{fstr(o.ordernotes) && (
|
||||
<div className="zone-order-line zone-order-notes"><StickyNote size={11} /> {fstr(o.ordernotes)}</div>
|
||||
@@ -727,9 +718,13 @@ function FocusedDetail({
|
||||
// ── Customer card ───────────────────────────────────────────────────────────────────
|
||||
function CustomerCard({ g, onClick, isSelected }: { g: Group; onClick: () => void; isSelected?: boolean }) {
|
||||
const customer = g.raw;
|
||||
const name = customer ? fstr(customer.customername) || fstr(customer.name) : g.name;
|
||||
// g.name is already resolved through customerName(); reading `customername` off
|
||||
// the raw row (a column gettenantcustomers doesn't return) made every card in
|
||||
// the list read "Unknown Customer".
|
||||
const name = g.name;
|
||||
const phone = customer ? fstr(customer.contactno) || fstr(customer.phone) : '';
|
||||
|
||||
const deliveries = g.orders.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rcard transition-all duration-300 cursor-pointer overflow-hidden relative group ${
|
||||
@@ -757,6 +752,20 @@ function CustomerCard({ g, onClick, isSelected }: { g: Group; onClick: () => voi
|
||||
<Phone size={10} /> {phone}
|
||||
</div>
|
||||
)}
|
||||
{(g.storeName || deliveries > 0) && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5 flex-wrap">
|
||||
{g.storeName && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">
|
||||
<Store size={9} /> {g.storeName}
|
||||
</span>
|
||||
)}
|
||||
{deliveries > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
|
||||
<Truck size={9} /> {deliveries} today
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,11 +88,27 @@ export default function Header({
|
||||
/>
|
||||
</span>
|
||||
|
||||
{/* Sidebar toggle (Burger Menu) */}
|
||||
{/* Sidebar toggle (Burger Menu).
|
||||
ml-auto when open pins it to the trailing edge of the brand cell,
|
||||
and since that cell's width tracks the sidebar exactly (256px open
|
||||
/ 64px collapsed) the button lands on the sidebar's own edge — the
|
||||
seam between the panel and the header. Left to sit inline after
|
||||
the wordmark it floated in the middle of the open panel instead,
|
||||
and pushed outside the cell it drifted past the seam by the flex
|
||||
gap. Anchoring to the cell needs no pixel offsets and holds if
|
||||
either width changes.
|
||||
|
||||
Collapsed the cell is only 64px and centred, so the natural
|
||||
inline position already sits beside the icon logo, which is itself
|
||||
aligned over the sidebar's icon rail. */}
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
title="Toggle sidebar"
|
||||
className={`rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white ${isSidebarOpen ? 'p-1.5 sm:p-2' : 'p-1.5'}`}
|
||||
title={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
|
||||
aria-label={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
|
||||
aria-expanded={isSidebarOpen}
|
||||
className={`rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white shrink-0 ${
|
||||
isSidebarOpen ? 'md:ml-auto p-1.5 sm:p-2' : 'p-1.5'
|
||||
}`}
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
|
||||
@@ -273,7 +273,9 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
||||
name: fstr(c.fullname) || `${fstr(c.firstname)} ${fstr(c.lastname)}`.trim() || 'Customer',
|
||||
phone: fstr(c.contactno) || '—',
|
||||
email: fstr(c.email),
|
||||
address: fstr(c.address) || 'Coimbatore',
|
||||
// Was `|| 'Coimbatore'`, which invented an address for every customer
|
||||
// whose record has none — and for every tenant outside Coimbatore.
|
||||
address: fstr(c.address) || '—',
|
||||
ordersCount: Number(c.orderscount) || 0,
|
||||
totalSpent: spent > 0 ? `₹${spent.toLocaleString('en-IN')}` : '—'
|
||||
};
|
||||
|
||||
@@ -106,6 +106,30 @@ export function num(v: unknown): number {
|
||||
|
||||
export const str = (v: unknown): string => (v == null ? '' : String(v));
|
||||
|
||||
/**
|
||||
* Display name for a customer row. `gettenantcustomers` returns `firstname` /
|
||||
* `lastname` and has NO `customername` or `name` column, so code that read those
|
||||
* fell through to "Unknown Customer" for every customer on the platform. Delivery
|
||||
* rows spell the same person `deliverycustomer`, hence the extra fallbacks.
|
||||
*/
|
||||
export function customerName(r: Row): string {
|
||||
const full = `${str(r.firstname).trim()} ${str(r.lastname).trim()}`.trim();
|
||||
return (
|
||||
full ||
|
||||
str(r.deliverycustomer).trim() ||
|
||||
str(r.customername).trim() ||
|
||||
str(r.name).trim()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The store a customer belongs to. `gettenantcustomers` aliases the
|
||||
* `tenantcustomers.locationid` link as `tenantlocationid` — plain `locationid`
|
||||
* is NOT in the response, so filtering on it matched nothing. `deliverylocationid`
|
||||
* is the saved-address id, a different thing entirely, and must not be used here.
|
||||
*/
|
||||
export const customerStoreId = (r: Row): number => num(r.tenantlocationid) || num(r.locationid);
|
||||
|
||||
/** Fiesta date params want a bare `YYYY-MM-DD`. */
|
||||
export const ymd = (d: Date) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
@@ -352,27 +376,48 @@ export async function getDeliveries(opts: {
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}): Promise<Row[]> {
|
||||
const rows = toRows(
|
||||
await fiestaGet('deliveries/getdeliveries', {
|
||||
tenantid: opts.tenantid,
|
||||
// NOTE: do NOT send `locationid` to getdeliveries — the backend's locationid
|
||||
// filter on THIS endpoint is broken: passing a real outlet id returns []
|
||||
// (it doesn't match against the row's own `locationid`), even though
|
||||
// deliverysummary honours the same id and the rows clearly carry it. So we
|
||||
// fetch tenant-wide here and scope by locationid client-side below; the KPI
|
||||
// strip (deliverysummary) keeps using the working server-side filter.
|
||||
applocationid: opts.applocationid,
|
||||
// The backend treats `status` as a LITERAL orderstatus filter — passing
|
||||
// 'all' matches nothing (returns []). Send empty to fetch every status and
|
||||
// let the board filter client-side by its status tabs.
|
||||
status: !opts.status || opts.status === 'all' ? '' : opts.status,
|
||||
fromdate: opts.fromdate,
|
||||
todate: opts.todate,
|
||||
keyword: opts.keyword,
|
||||
pageno: opts.pageno ?? 1,
|
||||
pagesize: opts.pagesize ?? 200,
|
||||
}),
|
||||
);
|
||||
const pagesize = opts.pagesize ?? 200;
|
||||
const fetchPage = async (pageno: number) =>
|
||||
toRows(
|
||||
await fiestaGet('deliveries/getdeliveries', {
|
||||
tenantid: opts.tenantid,
|
||||
// NOTE: do NOT send `locationid` to getdeliveries — the backend's locationid
|
||||
// filter on THIS endpoint is broken: passing a real outlet id returns []
|
||||
// (it doesn't match against the row's own `locationid`), even though
|
||||
// deliverysummary honours the same id and the rows clearly carry it. So we
|
||||
// fetch tenant-wide here and scope by locationid client-side below; the KPI
|
||||
// strip (deliverysummary) keeps using the working server-side filter.
|
||||
applocationid: opts.applocationid,
|
||||
// The backend treats `status` as a LITERAL orderstatus filter — passing
|
||||
// 'all' matches nothing (returns []). Send empty to fetch every status and
|
||||
// let the board filter client-side by its status tabs.
|
||||
status: !opts.status || opts.status === 'all' ? '' : opts.status,
|
||||
fromdate: opts.fromdate,
|
||||
todate: opts.todate,
|
||||
keyword: opts.keyword,
|
||||
pageno,
|
||||
pagesize,
|
||||
}),
|
||||
);
|
||||
|
||||
let rows: Row[];
|
||||
if (opts.pageno) {
|
||||
// An explicit page was asked for — honour it and don't walk the rest.
|
||||
rows = await fetchPage(opts.pageno);
|
||||
} else {
|
||||
// Walk every page. The endpoint has no total-count field, so a short page is
|
||||
// the only end-of-data signal. Previously this fetched page 1 only, which
|
||||
// silently dropped delivery 201+ for a busy tenant-wide day — the dispatch
|
||||
// board looked complete while missing stops. MAX_PAGES caps a runaway loop
|
||||
// if the backend ever ignores `pageno` and keeps returning full pages.
|
||||
const MAX_PAGES = 25;
|
||||
rows = [];
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const batch = await fetchPage(page);
|
||||
rows.push(...batch);
|
||||
if (batch.length < pagesize) break;
|
||||
}
|
||||
}
|
||||
return opts.locationid ? rows.filter((r) => num(r.locationid) === opts.locationid) : rows;
|
||||
}
|
||||
|
||||
@@ -758,7 +803,14 @@ export async function getTenantCustomers(opts: {
|
||||
locationid: opts.locationid,
|
||||
keyword: opts.keyword ?? '',
|
||||
pageno: opts.pageno ?? 1,
|
||||
pagesize: opts.pagesize ?? 20,
|
||||
// When a store is named the backend joins `customerlocations`, so it
|
||||
// returns one row per SAVED ADDRESS and applies LIMIT to those rows — not
|
||||
// to customers. Live: locationid=1185 → 12 rows → 2 customers (11 of them
|
||||
// one person's addresses). The old default of 20 therefore showed a store
|
||||
// roughly three customers. Ask for enough rows that dedupe still has every
|
||||
// customer to work with; the backend's DISTINCT ON fix makes this generous
|
||||
// rather than load-bearing.
|
||||
pagesize: opts.pagesize ?? 500,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -423,7 +423,11 @@ export function useFiestaRiderPeriodicLogs(opts: {
|
||||
return useQuery({
|
||||
queryKey: fiestaKeys.riderPeriodicLogs(opts),
|
||||
queryFn: () => getRiderPeriodicLogs(opts),
|
||||
enabled: Boolean(opts.fromdate && opts.todate),
|
||||
// A rider is required. Without this guard the query fired on every page load
|
||||
// with no rider selected — and `riders/getriderperiodiclogs` 404s in both the
|
||||
// riders/ and partners/ namespaces (no such backend route exists), so every
|
||||
// load spent a request on a guaranteed failure.
|
||||
enabled: Boolean((opts.userid || opts.riderid) && opts.fromdate && opts.todate),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user