diff --git a/src/components/DeliveriesView.tsx b/src/components/DeliveriesView.tsx index fd6f198..d93e335 100644 --- a/src/components/DeliveriesView.tsx +++ b/src/components/DeliveriesView.tsx @@ -18,8 +18,8 @@ import { createPortal } from 'react-dom'; import { Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike, } from 'lucide-react'; -import { useFiestaDeliverySummary, useFiestaDeliveries, useFiestaRiders, useFiestaOrderDetails } from '../services/fiestaQueries'; -import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi'; +import { useFiestaDeliverySummary, useFiestaDeliveries, useFiestaRiders, useFiestaOrderDetails, useFiestaChangeRider, 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 AwaitingApi from './AwaitingApi'; import { @@ -27,7 +27,7 @@ import { DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, } from './consoleUi'; -interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; date?: string; } +interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; applocationid?: number; date?: string; } type DeliveryStatus = 'pending' | 'accepted' | 'arrived' | 'picked' | 'active' | 'skipped' | 'delivered' | 'cancelled'; const STATUS_TABS: Array<{ key: DeliveryStatus; label: string }> = [ @@ -57,7 +57,7 @@ function inBatch(r: Row, b: BatchId): boolean { return h >= 16 && h < 19; } -export default function DeliveriesView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID, date }: DeliveriesViewProps) { +export default function DeliveriesView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID, applocationid = FIESTA_APPLOCATION_ID, date }: DeliveriesViewProps) { const today = new Date(); const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); }; @@ -89,7 +89,15 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId // the whole day (status='all', large pagesize); status/search filter client-side. const summaryQ = useFiestaDeliverySummary({ tenantid: tenantId, fromdate, todate, locationid }); const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate, todate, locationid, status: 'all', pagesize: 200 }); - const ridersQ = useFiestaRiders({ tenantid: tenantId }); + // Scoped by app-location, not tenant: rider records leave app_users.tenantid + // unset, so a tenant-scoped call returns an empty list and the change-rider + // dropdown would have nothing in it. Prefer the app-location the visible + // deliveries name, falling back to the caller's. + const rowApplocationId = useMemo( + () => fnum((deliveriesQ.data ?? []).find((r) => fnum(r.applocationid))?.applocationid), + [deliveriesQ.data], + ); + const ridersQ = useFiestaRiders({ applocationid: rowApplocationId || applocationid || undefined }); const allRows = deliveriesQ.data ?? []; const summary = summaryQ.data; @@ -243,13 +251,142 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId - {detailRow && setDetailRow(null)} />} + {detailRow && setDetailRow(null)} />} + + ); +} + +/** + * Change-rider / notify controls for one delivery. + * + * Change-rider is offered only while the delivery is still pending, accepted or + * arrived — reassigning resets orderstatus to 'pending', so allowing it once the + * parcel is picked up or delivered would rewind a completed journey. + * + * Notify and reassign are reported separately on purpose. The reassign is + * committed the moment it returns; a push that then fails leaves a rider + * holding work nobody told them about, which is worth saying out loud rather + * than folding into a single "done". + */ +function RiderActions({ row, riders }: { row: Row; riders: Row[] }) { + const st = fstr(row.orderstatus).toLowerCase(); + const deliveryid = fnum(row.deliveryid); + const orderheaderid = fnum(row.orderheaderid); + const canChange = ['pending', 'accepted', 'arrived'].includes(st) && deliveryid > 0; + const canNotify = st !== 'delivered' && st !== 'cancelled'; + + const [picked, setPicked] = useState(0); + const [msg, setMsg] = useState(''); + const changeMut = useFiestaChangeRider(); + const notifyMut = useFiestaNotifyRider(); + + const options = useMemo( + () => + riders + .map((r) => ({ + id: fnum(r.userid), + label: (fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`).trim(), + token: fstr(r.userfcmtoken), + })) + .filter((o) => o.id > 0 && o.label), + [riders], + ); + + // The rider currently on the delivery. Deliveries carry the token on the row + // itself, so notifying does not depend on them still being in the on-duty list. + const currentToken = fstr(row.userfcmtoken); + + const push = async (token: string, body: string, data?: Record, okLabel = 'Rider notified') => { + try { + await notifyMut.mutateAsync({ token, body, data }); + setMsg(okLabel); + } catch (err) { + setMsg( + err instanceof RiderNotReachableError + ? 'Not sent — this rider has no device registered' + : 'Not sent — the push failed, tell them another way', + ); + } + }; + + const handleChange = async () => { + if (!picked) return; + const option = options.find((o) => o.id === picked); + try { + await changeMut.mutateAsync({ deliveryid, orderheaderid, userid: picked }); + setMsg(`Moved to ${option?.label ?? 'rider'} · notifying…`); + await push(option?.token ?? '', RIDER_MESSAGES.reassigned, undefined, `Moved to ${option?.label ?? 'rider'} · notified`); + } catch { + setMsg('Could not change the rider — please retry.'); + } + }; + + const busy = changeMut.isPending || notifyMut.isPending; + + return ( +
+

Rider actions

+ + {canChange ? ( +
+ + +
+ ) : ( +

+ {st === 'delivered' || st === 'cancelled' + ? `This delivery is ${st} — the rider can no longer be changed.` + : 'The rider can only be changed while a delivery is pending, accepted or arrived.'} +

+ )} + + {canNotify && ( +
+ + +
+ )} + + {msg &&

{msg}

}
); } // ── Delivery details modal ────────────────────────────────────────────────────── -function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }) { +function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]; onClose: () => void }) { const orderheaderid = row.orderheaderid ?? row.orderid; const detailsQ = useFiestaOrderDetails(orderheaderid as number | string); const lines = (detailsQ.data ?? []).map((d) => { @@ -312,7 +449,7 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void } ))} - +
diff --git a/src/components/OrdersView.tsx b/src/components/OrdersView.tsx index b45d621..c652e68 100644 --- a/src/components/OrdersView.tsx +++ b/src/components/OrdersView.tsx @@ -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(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(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