Fix the empty rider dropdown and notify riders on assign
The assign dropdown was empty on open. Two faults compounded: riders were queried by tenantid, and the source list defaulted to a tab that could never match. /partners/getriders returns nothing for any tenant, because a rider record leaves app_users.tenantid unset — riders belong to a partner and an app-location. Scoping by applocationid returns them, so both Orders and Deliveries now take the app-location from the rows they are showing and fall back to the caller's. The second fault was the "Store Fleet" default, which filtered for partnerid === 0. Every on-duty rider has a partnerid, so the default tab was always empty even once the query returned rows. It is now On Duty / This Partner, the latter enabled only when the orders name a partner. Dropped the getallusers?roleid=5 "own fleet" list it merged in. There is no rider role: app_roles defines 1-6 as Super admin / Operations / Admin / Manager per configid, and riders are identified by configid=6 inside getriders. roleid=5 matched a single user with two deliveries in the platform's history, while the users actually driving deliveries carry roleid 0. /partners/getriders is already a presence query rather than a roster — it requires status Active, onduty=1 and a riderlog dated today with logstatus=0 — so the list is riders working right now, and it carries the userfcmtoken needed to reach them. Added a refetch so someone logging off mid-shift drops out of the list. Riders are now notified. The push runs after the write and is reported separately: the deliveries are committed by then, so a failed push must not read as a failed assignment, but it must still be visible because a rider who was never told has work sitting unseen. A missing token is reported as a rider with no device registered rather than as a transport failure, since the remedy is different. Deliveries gains the rider actions its placeholder promised: change rider, notify, and send-cancellation carrying data.type=cancel. Change-rider is offered only while a delivery is pending, accepted or arrived, because reassigning resets orderstatus to pending and would otherwise rewind a journey already completed. reassignDeliveries was posting to /riders/reassigndeliveries, which is not registered on the backend and answers 404. It had no callers, so the failure had never been observed. It now goes through updatedelivery, one call per delivery, tolerating partial failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,8 +14,11 @@
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2, Download, UserCheck, ClipboardList, ArrowLeft } from 'lucide-react';
|
||||
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider, useFiestaUsers } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider, useFiestaNotifyRider } from '../services/fiestaQueries';
|
||||
import {
|
||||
FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, RIDER_MESSAGES, RiderNotReachableError,
|
||||
num as fnum, str as fstr, ymd, type Row,
|
||||
} from '../services/fiestaApi';
|
||||
import { shortTime } from '../services/fiestaMappers';
|
||||
import {
|
||||
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
|
||||
@@ -27,6 +30,12 @@ interface OrdersViewProps {
|
||||
locationid?: number;
|
||||
/** Merchant tenant to scope to; defaults to the shared constant. */
|
||||
tenantId?: number;
|
||||
/**
|
||||
* App-location to source assignable riders from. Riders are scoped by
|
||||
* app-location rather than tenant, so without one the rider list falls back
|
||||
* to whatever the visible orders name. Defaults to the platform constant.
|
||||
*/
|
||||
applocationid?: number;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
@@ -40,7 +49,13 @@ const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [
|
||||
];
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export default function OrdersView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID, date }: OrdersViewProps) {
|
||||
export default function OrdersView({
|
||||
searchQuery = '',
|
||||
locationid,
|
||||
tenantId = FIESTA_TENANT_ID,
|
||||
applocationid = FIESTA_APPLOCATION_ID,
|
||||
date,
|
||||
}: OrdersViewProps) {
|
||||
const today = new Date();
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const [fromdate, setFromdate] = useState<string>(date || ymd(today));
|
||||
@@ -81,6 +96,7 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
const [assignMsg, setAssignMsg] = useState('');
|
||||
const [showSelected, setShowSelected] = useState(false); // full-page review of selection
|
||||
const assignMut = useFiestaAssignRider();
|
||||
const notifyMut = useFiestaNotifyRider();
|
||||
|
||||
// Ctrl/Cmd+K focuses search; Escape blurs it (parity with the ops console).
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
@@ -105,7 +121,10 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
setShowSelected(false);
|
||||
}, [fromdate, todate, status, branch, pageno, locationid]);
|
||||
|
||||
const [riderSource, setRiderSource] = useState<'own' | 'partner'>('own');
|
||||
// 'all' lists every rider on duty at this app-location; 'partner' narrows to
|
||||
// the partner already carrying the selected orders. Defaults to 'all' — the
|
||||
// old default filtered to riders with no partnerid, which no on-duty rider has.
|
||||
const [riderSource, setRiderSource] = useState<'all' | 'partner'>('all');
|
||||
|
||||
// Scope to the user's store when a locationid is supplied (server-side per the
|
||||
// backend's getordersummary/getorders locationid param); tenant-wide otherwise.
|
||||
@@ -114,51 +133,54 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
const summary = summaryQ.data;
|
||||
const rawRows = ordersQ.data ?? [];
|
||||
|
||||
// Riders must share the orders' tenant + partner to be assignable (the backend
|
||||
// rejects cross-tenant/partner riders), so derive the partner/app-location from
|
||||
// the live order rows and scope the rider list to them. An out-of-tenant rider
|
||||
// simply won't appear — the intended guard.
|
||||
// Riders are scoped by app-location, NOT by tenant. A rider record carries a
|
||||
// partnerid and an applocationid but leaves app_users.tenantid unset, so
|
||||
// /partners/getriders?tenantid=… returns an empty list for every tenant —
|
||||
// which is what the assign dropdown used to show. The app-location is taken
|
||||
// from the live order rows, falling back to the signed-in user's own.
|
||||
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 riderApplocationId = orderApplocationId || applocationid || 0;
|
||||
|
||||
// /partners/getriders is already a live-presence query, not a roster: the
|
||||
// backend filters on status='Active', onduty=1 and a riderlog dated today
|
||||
// with logstatus=0, joined to each rider's most recent GPS ping. So this
|
||||
// returns riders who are on shift and logged in right now, and it carries the
|
||||
// userfcmtoken needed to notify them.
|
||||
const ridersQ = useFiestaRiders({
|
||||
tenantid: tenantId,
|
||||
applocationid: orderApplocationId || undefined,
|
||||
// We omit partnerid here to fetch all partner riders for the location at once.
|
||||
});
|
||||
|
||||
const internalRidersQ = useFiestaUsers({
|
||||
tenantid: tenantId,
|
||||
roleid: 5, // 5 = Rider role
|
||||
pagesize: 500
|
||||
applocationid: riderApplocationId || undefined,
|
||||
});
|
||||
|
||||
// The previous build also merged in getallusers?roleid=5 as an "own fleet".
|
||||
// There is no rider role: app_roles only defines 1-6 as Super admin /
|
||||
// Operations / Admin / Manager per configid, and riders are identified by
|
||||
// configid=6 inside getriders. roleid=5 matched a single user with two
|
||||
// deliveries in the platform's history, while the 29 users who actually drive
|
||||
// the bulk of deliveries carry roleid 0. Every on-duty rider also has
|
||||
// partnerid > 0, so the "own fleet" tab — the default — filtered that list
|
||||
// down to nothing and the dropdown was empty on open.
|
||||
const riderOptions = useMemo(
|
||||
() => {
|
||||
const externalRiders = ridersQ.data ?? [];
|
||||
const internalRiders = internalRidersQ.data ?? [];
|
||||
const allRiders = [...externalRiders, ...internalRiders];
|
||||
|
||||
const filtered = allRiders.filter((r) => {
|
||||
const pId = fnum(r.partnerid);
|
||||
if (riderSource === 'own') {
|
||||
// Store fleet riders are internal users (they have no partner id)
|
||||
return !pId || pId === 0;
|
||||
} else {
|
||||
// Partner riders belong to a 3rd party (partnerid > 0)
|
||||
// If the order already has a specific partnerid, we only show riders from that partner.
|
||||
return pId > 0 && (!orderPartnerId || pId === orderPartnerId);
|
||||
}
|
||||
});
|
||||
|
||||
return filtered
|
||||
() =>
|
||||
(ridersQ.data ?? [])
|
||||
.filter((r) => {
|
||||
if (riderSource === 'all') return true;
|
||||
// Restrict to the partner already carrying these orders, when the
|
||||
// rows name one; otherwise there is nothing to narrow to.
|
||||
const pId = fnum(r.partnerid);
|
||||
return !orderPartnerId || pId === orderPartnerId;
|
||||
})
|
||||
.map((r) => ({
|
||||
id: fnum(r.userid),
|
||||
label: `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() + (fstr(r.contactno) ? ` · ${fstr(r.contactno)}` : ''),
|
||||
label:
|
||||
(fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`).trim() +
|
||||
(fstr(r.contactno) ? ` · ${fstr(r.contactno)}` : ''),
|
||||
// Carried so the assign can notify the rider, and so a rider with no
|
||||
// registered device can be called out rather than silently skipped.
|
||||
token: fstr(r.userfcmtoken),
|
||||
vehicle: fstr(r.vehiclename),
|
||||
}))
|
||||
.filter((o) => o.id > 0 && o.label);
|
||||
},
|
||||
[ridersQ.data, internalRidersQ.data, riderSource, orderPartnerId],
|
||||
.filter((o) => o.id > 0 && o.label),
|
||||
[ridersQ.data, riderSource, orderPartnerId],
|
||||
);
|
||||
|
||||
// Branches (app-locations) present in the data — drives the branch filter so the
|
||||
@@ -271,16 +293,42 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
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';
|
||||
const option = riderOptions.find((o) => o.id === assignRiderId);
|
||||
const rider = option?.label ?? 'rider';
|
||||
try {
|
||||
const res = await assignMut.mutateAsync({ userid: assignRiderId, orders: toAssign });
|
||||
setAssignMsg(
|
||||
const assigned =
|
||||
res.failed
|
||||
? `Assigned ${res.ok}/${res.total} to ${rider} · ${res.failed} failed`
|
||||
: `Assigned ${res.ok} order${res.ok === 1 ? '' : 's'} to ${rider}`,
|
||||
);
|
||||
: `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
|
||||
|
||||
// Notify only for work that actually landed. The push runs after the
|
||||
// write and is reported separately: the deliveries exist either way, so a
|
||||
// failed notification must not read as a failed assignment — but it must
|
||||
// still be visible, because a rider who was never told has work sitting
|
||||
// unseen.
|
||||
if (res.ok === 0) {
|
||||
setAssignMsg(assigned);
|
||||
return;
|
||||
}
|
||||
setAssignMsg(`${assigned} · notifying…`);
|
||||
try {
|
||||
await notifyMut.mutateAsync({
|
||||
token: option?.token ?? '',
|
||||
body: RIDER_MESSAGES.assigned(res.ok),
|
||||
});
|
||||
setAssignMsg(`${assigned} · rider notified`);
|
||||
} catch (err) {
|
||||
setAssignMsg(
|
||||
`${assigned} · NOT notified — ${
|
||||
err instanceof RiderNotReachableError
|
||||
? 'this rider has no device registered'
|
||||
: 'the push failed, tell them another way'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setAssignMsg('Assignment failed — please retry.');
|
||||
}
|
||||
@@ -371,18 +419,21 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
</span>
|
||||
<div className="flex bg-white rounded-full p-0.5 ml-2 border" style={{ borderColor: edge(BRAND) }}>
|
||||
<button
|
||||
onClick={() => setRiderSource('own')}
|
||||
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors ${riderSource === 'own' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
style={riderSource === 'own' ? { background: tint(BRAND), color: BRAND } : undefined}
|
||||
onClick={() => setRiderSource('all')}
|
||||
title="Every rider on duty at this branch right now"
|
||||
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors ${riderSource === 'all' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
style={riderSource === 'all' ? { background: tint(BRAND), color: BRAND } : undefined}
|
||||
>
|
||||
Store Fleet
|
||||
On Duty
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRiderSource('partner')}
|
||||
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors ${riderSource === 'partner' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
disabled={!orderPartnerId}
|
||||
title={orderPartnerId ? 'Only riders from the partner carrying these orders' : 'These orders name no partner'}
|
||||
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors disabled:opacity-40 ${riderSource === 'partner' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
style={riderSource === 'partner' ? { background: tint(BRAND), color: BRAND } : undefined}
|
||||
>
|
||||
Partners
|
||||
This Partner
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
@@ -393,7 +444,15 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
||||
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 ${riderSource === 'own' ? 'store' : 'partner'} riders available`}</option>
|
||||
<option value={0}>
|
||||
{ridersQ.isLoading
|
||||
? 'Loading riders…'
|
||||
: riderOptions.length
|
||||
? 'Select rider…'
|
||||
: riderSource === 'partner'
|
||||
? 'No riders on duty for this partner'
|
||||
: 'No riders on duty right now'}
|
||||
</option>
|
||||
{riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user