console update
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user