Files
daily_merchant_web/src/components/OrdersView.tsx
abhishek 8deaf8513b 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>
2026-07-31 12:48:57 +05:30

782 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Orders page — replicated from the operations console (nearle_console/orders),
* rebuilt in the merchant stack against the shared console UI kit (`./consoleUi`)
* so it matches the source design: brand purple #662582, gradient header, KPI
* cards with gradient top-bars, pill status tabs, and a status-chip table. Wired
* to the live Fiesta order endpoints (status-scoped, date-ranged, paginated).
*/
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, 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,
ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge, ring,
} from './consoleUi';
interface OrdersViewProps {
searchQuery?: string;
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;
}
type StatusKey = 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled';
const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [
{ key: 'created', label: 'Created' },
{ key: 'pending', label: 'Pending' },
{ key: 'processing', label: 'Processing' },
{ key: 'delivered', label: 'Delivered' },
{ key: 'cancelled', label: 'Cancelled' },
];
const PAGE_SIZE = 25;
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));
const [todate, setTodate] = useState<string>(date || ymd(today));
// Sync internal date range if the prop changes from the Hub header
useEffect(() => {
if (date) {
setFromdate(date);
setTodate(date);
}
}, [date]);
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); };
const dayAhead = (n: number) => { const d = new Date(); d.setDate(d.getDate() + n); return ymd(d); };
// NOTE: the backend lists orders by DELIVERY date (deliverytime), not creation
// date — so an order created today for a future slot only appears once the range
// covers its delivery date. "Next 7 Days" surfaces upcoming-delivery orders.
// "All time" can't pass empty dates (the query is gated on from/to), so it uses
// a wide window — from the platform's earliest plausible data to a year ahead.
const presets = [
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
{ key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) },
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: dayAhead(7) },
];
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
const [status, setStatus] = useState<StatusKey>('created');
const [pageno, setPageno] = useState(1);
const [localSearch, setLocalSearch] = useState('');
const [branch, setBranch] = useState(0); // applocationid filter (0 = all branches)
const [detailOrder, setDetailOrder] = useState<Row | null>(null);
// ── Multi-select rider assignment (parity with the ops console) ─────────────
const [selected, setSelected] = useState<Set<string>>(new Set());
const [assignRiderId, setAssignRiderId] = useState(0);
const [assignMsg, setAssignMsg] = useState('');
const [showSelected, setShowSelected] = useState(false); // full-page review of selection
const assignMut = useFiestaAssignRider();
const notifyMut = useFiestaNotifyRider();
// Ctrl/Cmd+K focuses search; Escape blurs it (parity with the ops console).
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
searchRef.current?.focus();
} else if (e.key === 'Escape' && document.activeElement === searchRef.current) {
searchRef.current?.blur();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
// Reset the selection whenever the visible result set changes, so an assign
// can never act on rows the operator can no longer see.
useEffect(() => {
setSelected(new Set());
setAssignMsg('');
setShowSelected(false);
}, [fromdate, todate, status, branch, pageno, locationid]);
// '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.
const summaryQ = useFiestaOrderSummary(tenantId, fromdate, todate, locationid);
const ordersQ = useFiestaOrders({ tenantid: tenantId, status, fromdate, todate, locationid, pageno, pagesize: PAGE_SIZE });
const summary = summaryQ.data;
const rawRows = ordersQ.data ?? [];
// 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({
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(
() =>
(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.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, riderSource, orderPartnerId],
);
// Branches (app-locations) present in the data — drives the branch filter so the
// operator can see which branch an order was placed at. Each order row carries
// applocationid + applocation (the app-location name).
const branches = useMemo(() => {
const m = new Map<number, string>();
for (const r of rawRows) {
const id = fnum(r.applocationid);
if (id && !m.has(id)) m.set(id, fstr(r.applocation) || fstr(r.locationname) || `Branch ${id}`);
}
return [...m.entries()].map(([id, name]) => ({ id, name }));
}, [rawRows]);
const rows = useMemo(() => {
const term = (localSearch || searchQuery).toLowerCase();
return rawRows.filter((r) => {
if (locationid && fnum(r.locationid) !== locationid) return false;
if (branch && fnum(r.applocationid) !== branch) return false;
if (!term) return true;
// Broad match across every order field shown or relevant (mirrors the ops
// console search): id, both parties + contacts + addresses, branch, rider,
// status, and notes.
return [
r.orderid, r.orderstatus, r.ordernotes, r.tenantname,
r.pickupcustomer, r.pickupcontactno, r.pickupsuburb, r.pickupaddress, r.pickuplocation,
r.deliverycustomer, r.deliverycontactno, r.deliverysuburb, r.deliveryaddress, r.deliverylocation,
r.applocation, r.locationname, r.ridername,
].some((v) => fstr(v).toLowerCase().includes(term));
});
}, [rawRows, localSearch, searchQuery, locationid, branch]);
// Footer totals across the filtered rows (parity with the ops console's
// Total Charges / Total Amount summary).
const totals = useMemo(() => {
let cod = 0, charges = 0, amount = 0;
for (const r of rows) {
cod += fnum(r.collectionamt);
charges += fnum(r.deliverycharge) || fnum(r.deliverycharges);
amount += fnum(r.orderamount) || fnum(r.deliveryamt);
}
return { cod, charges, amount };
}, [rows]);
const inr = (n: number) => `${n.toLocaleString('en-IN')}`;
// Export the currently-filtered orders to CSV (RFC-4180 quoting).
const exportCsv = () => {
const headers = ['#', 'Order ID', 'Status', 'Branch', 'Order Date', 'Pickup', 'Pickup Contact', 'Pickup Address', 'Drop', 'Drop Contact', 'Drop Address', 'Qty', 'COD', 'KMs', 'Charges', 'Amount'];
const esc = (v: unknown) => `"${fstr(v).replace(/"/g, '""')}"`;
const lines = rows.map((r, i) => [
i + 1, fstr(r.orderid) || fstr(r.orderheaderid), fstr(r.orderstatus), fstr(r.applocation) || fstr(r.locationname),
shortTime(r.orderdate || r.deliverydate), fstr(r.pickupcustomer) || fstr(r.tenantname), fstr(r.pickupcontactno),
fstr(r.pickupaddress) || fstr(r.pickupsuburb), fstr(r.deliverycustomer), fstr(r.deliverycontactno),
fstr(r.deliveryaddress) || fstr(r.deliverysuburb), fnum(r.quantity), fnum(r.collectionamt),
fnum(r.kms), fnum(r.deliverycharge) || fnum(r.deliverycharges), fnum(r.orderamount) || fnum(r.deliveryamt),
].map(esc).join(','));
const blob = new Blob([[headers.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Orders_${status}_${fromdate}_to_${todate}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const hasNext = rawRows.length === PAGE_SIZE;
const total = summary?.total ?? 0;
const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0);
const countFor = (key: StatusKey): number => (summary ? (summary[key] ?? 0) : 0);
// Restrained, professional palette — deep muted tones (not neon) so the KPI
// strip reads as a serious business dashboard rather than a colourful one.
const kpis = [
{ label: 'Created Orders', value: (summary?.created ?? 0).toLocaleString('en-IN'), color: '#6366f1', icon: <ShoppingBag size={20} />, badge: `${pct(summary?.created ?? 0)}% of total` },
{ label: 'Pending Orders', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#f59e0b', icon: <Clock size={20} />, badge: `${pct(summary?.pending ?? 0)}% of total` },
{ label: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} />, badge: `${pct(summary?.delivered ?? 0)}% of total` },
{ label: 'Cancelled Orders', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#f43f5e', icon: <XCircle size={20} />, badge: `${pct(summary?.cancelled ?? 0)}% of total` },
];
const setScope = (next: Partial<{ status: StatusKey; from: string; to: string }>) => {
if (next.status) setStatus(next.status);
if (next.from) setFromdate(next.from);
if (next.to) setTodate(next.to);
setPageno(1);
};
// ── Selection helpers ───────────────────────────────────────────────────────
const rowKey = (r: Row) => fstr(r.orderheaderid) || fstr(r.orderid);
const assignableRows = rows.filter((r) => fstr(r.orderstatus).toLowerCase() === 'created');
const assignableKeys = assignableRows.map(rowKey);
const allSelected = assignableKeys.length > 0 && assignableKeys.every((k) => selected.has(k));
const toggleRow = (k: string) =>
setSelected((prev) => {
const n = new Set(prev);
if (n.has(k)) n.delete(k);
else n.add(k);
return n;
});
const toggleAll = () =>
setSelected((prev) => {
const n = new Set(prev);
if (allSelected) assignableKeys.forEach((k) => n.delete(k));
else assignableKeys.forEach((k) => n.add(k));
return n;
});
const handleAssign = async () => {
if (!assignRiderId || selected.size === 0) return;
const toAssign = rows.filter((r) => selected.has(rowKey(r)));
const option = riderOptions.find((o) => o.id === assignRiderId);
const rider = option?.label ?? 'rider';
try {
const res = await assignMut.mutateAsync({ userid: assignRiderId, orders: toAssign });
const assigned =
res.failed
? `Assigned ${res.ok}/${res.total} to ${rider} · ${res.failed} failed`
: `Assigned ${res.ok} order${res.ok === 1 ? '' : 's'} to ${rider}`;
setSelected(new Set());
setShowSelected(false); // return to the board with the result shown in the bar
// 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.');
}
};
// Rows currently selected (selection is always within the visible page).
const selectedRows = useMemo(() => rows.filter((r) => selected.has(rowKey(r))), [rows, selected]);
return (
<div className="animate-in fade-in duration-300">
<div className="mb-4">
<KpiStrip items={kpis} loading={summaryQ.isLoading} />
</div>
{/* Date filter */}
<FilterBar className="mb-4">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center gap-1.5 text-[10px] font-extrabold uppercase tracking-widest pr-1" style={{ color: TEXT_2 }}>
<Calendar size={13} style={{ color: BRAND }} /> View
</span>
{presets.map((p) => (
<React.Fragment key={p.key}>
<Pill active={activePreset === p.key} color={BRAND} onClick={() => setScope({ from: p.from, to: p.to })}>{p.label}</Pill>
</React.Fragment>
))}
</div>
<div className="flex items-center gap-2 text-xs">
<input type="date" value={fromdate} max={todate} onChange={(e) => setScope({ from: e.target.value })}
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} />
<span style={{ color: TEXT_3 }}></span>
<input type="date" value={todate} min={fromdate} onChange={(e) => setScope({ to: e.target.value })}
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} />
</div>
</div>
</FilterBar>
{/* Status tabs + search */}
<FilterBar className="mb-4">
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
<div className="flex items-center gap-2 overflow-x-auto custom-scrollbar no-scrollbar py-0.5 flex-1 min-w-0 touch-scrolling">
{STATUS_TABS.map((t) => {
// Single brand accent for the tab row (calmer than per-status colours);
// the per-status hue still appears on the row Status chip where it aids scanning.
const color = BRAND;
return (
<React.Fragment key={t.key}>
<Pill active={status === t.key} color={color} onClick={() => setScope({ status: t.key })} count={summaryQ.isLoading ? '·' : countFor(t.key).toLocaleString('en-IN')}>
{t.label}
</Pill>
</React.Fragment>
);
})}
</div>
<div className="flex items-center gap-2 w-full lg:w-auto lg:shrink-0">
{branches.length > 1 && (
<select
value={branch}
onChange={(e) => { setBranch(Number(e.target.value)); setPageno(1); }}
title="Filter by branch / app-location"
className="rounded-full font-bold text-xs outline-none cursor-pointer shrink-0"
style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }}
>
<option value={0}>All branches</option>
{branches.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
)}
<div className="w-full lg:w-60"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders (Ctrl+K)…" inputRef={searchRef} /></div>
<button
onClick={exportCsv}
disabled={rows.length === 0}
title="Export current view to CSV"
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap shrink-0"
style={{ padding: '8px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
>
<Download size={13} /> CSV
</button>
</div>
</div>
</FilterBar>
{/* Multi-select assign bar — shown while rows are selected (or to report a result) */}
{(selected.size > 0 || assignMsg) && (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-2xl px-4 py-3 animate-in fade-in slide-in-from-top-1 duration-200" style={{ background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}` }}>
<span className="inline-flex items-center gap-1.5 font-extrabold text-xs" style={{ color: BRAND }}>
<UserCheck size={15} /> {selected.size} selected
</span>
<div className="flex bg-white rounded-full p-0.5 ml-2 border" style={{ borderColor: edge(BRAND) }}>
<button
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}
>
On Duty
</button>
<button
onClick={() => setRiderSource('partner')}
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}
>
This Partner
</button>
</div>
<select
value={assignRiderId}
onChange={(e) => setAssignRiderId(Number(e.target.value))}
disabled={selected.size === 0}
title="Choose a rider to assign"
className="rounded-full font-bold text-xs outline-none cursor-pointer disabled:opacity-50"
style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 260 }}
>
<option value={0}>
{ridersQ.isLoading
? 'Loading riders…'
: riderOptions.length
? 'Select rider…'
: 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
onClick={handleAssign}
disabled={!assignRiderId || selected.size === 0 || assignMut.isPending}
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
style={{ padding: '7px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
>
{assignMut.isPending ? <Loader2 size={13} className="animate-spin" /> : <UserCheck size={13} />} Assign rider
</button>
{selected.size > 0 && (
<button onClick={() => setSelected(new Set())} className="rounded-full font-bold text-xs cursor-pointer" style={{ padding: '7px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
Clear
</button>
)}
{assignMsg && <span className="text-[11px] font-semibold ml-auto" style={{ color: TEXT_2 }}>{assignMsg}</span>}
</div>
)}
{/* Table */}
<div className="bg-white border overflow-hidden" style={{ borderColor: BORDER }}>
<div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 960 }}>
<thead>
<tr>
<th className="px-3 py-2.5 text-left" style={TH_STYLE}>
<input type="checkbox" checked={allSelected} onChange={toggleAll} disabled={assignableRows.length === 0} aria-label="Select all assignable orders" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} />
</th>
{['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'KMs', 'Charges', 'Status', ''].map((h, i) => (
<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>
))}
</tr>
</thead>
<tbody>
{ordersQ.isLoading ? (
<tr><td colSpan={12} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
<span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading orders</span>
</td></tr>
) : rows.length === 0 ? (
<tr><td colSpan={12} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No orders found for this status, date range, or search.</td></tr>
) : (
rows.map((r, i) => {
const st = fstr(r.orderstatus).toLowerCase();
const cod = fnum(r.collectionamt);
const charges = fnum(r.deliverycharge) || fnum(r.deliverycharges);
return (
<tr key={fstr(r.orderid) || i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER_C}`, background: selected.has(rowKey(r)) ? tint(BRAND) : 'transparent' }}
onMouseEnter={(e) => { if (!selected.has(rowKey(r))) e.currentTarget.style.background = SURFACE_ALT; }} onMouseLeave={(e) => { e.currentTarget.style.background = selected.has(rowKey(r)) ? tint(BRAND) : 'transparent'; }}>
<td className="px-3 py-2.5">
{st === 'created' ? (
<input type="checkbox" checked={selected.has(rowKey(r))} onChange={() => toggleRow(rowKey(r))} aria-label="Select order" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} />
) : (
<input type="checkbox" disabled aria-label="Order cannot be assigned" style={{ width: 15, height: 15, opacity: 0.3 }} title="Only 'created' orders can be assigned" />
)}
</td>
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td>
<td className="px-3 py-2.5">
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
</td>
<td className="px-3 py-2.5">
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
<MapPin size={11} /> {fstr(r.applocation) || '—'}
</span>
{fstr(r.locationname) && <p className="text-[10px] truncate max-w-[130px]" style={{ color: TEXT_2 }}>{fstr(r.locationname)}</p>}
</td>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
</td>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
</td>
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td>
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: cod > 0 ? TEXT : TEXT_3 }}>{cod > 0 ? `${cod.toLocaleString('en-IN')}` : '—'}</td>
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: fnum(r.kms) ? TEXT_2 : TEXT_3 }}>{fnum(r.kms) ? fnum(r.kms).toFixed(1) : '—'}</td>
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: charges > 0 ? TEXT : TEXT_3 }}>{charges > 0 ? `${charges.toLocaleString('en-IN')}` : '—'}</td>
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
<td className="px-3 py-2.5 text-right">
<button onClick={() => setDetailOrder(r)} className="rounded-full font-extrabold cursor-pointer transition-colors"
style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>View</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Totals across the filtered rows */}
{rows.length > 0 && (
<div className="flex flex-wrap items-center justify-end gap-2 px-4 py-2.5 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<span className="text-[10px] font-extrabold uppercase tracking-wider mr-auto" style={{ color: TEXT_2 }}>Totals · {rows.length} order{rows.length === 1 ? '' : 's'}</span>
{totals.cod > 0 && <TotalChip label="COD" value={inr(totals.cod)} color={TEXT_2} />}
<TotalChip label="Charges" value={inr(totals.charges)} color={TEXT_2} />
<TotalChip label="Amount" value={inr(totals.amount)} color={BRAND} />
</div>
)}
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}>Page {pageno} · {rows.length} shown</span>
<div className="flex items-center gap-2">
<PagerBtn disabled={pageno === 1} onClick={() => setPageno((p) => Math.max(1, p - 1))}><ChevronLeft size={13} /> Prev</PagerBtn>
<PagerBtn disabled={!hasNext} onClick={() => setPageno((p) => p + 1)}>Next <ChevronRight size={13} /></PagerBtn>
</div>
</div>
</div>
{detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />}
{/* Right-edge floating badge — only on the Created tab and only when
MULTIPLE orders are selected (created orders are what get dispatched).
Opens the full-page review/assign view on click. */}
{status === 'created' && selected.size > 1 && !showSelected &&
createPortal(
<button
onClick={() => setShowSelected(true)}
title={`Review & assign ${selected.size} selected order${selected.size === 1 ? '' : 's'}`}
className="group fixed right-0 z-[150] flex items-center gap-2 py-3 pl-4 pr-5 text-white font-extrabold text-xs cursor-pointer transition-all duration-200 hover:pr-7 animate-in slide-in-from-right-4"
style={{ top: '70%', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, borderTopLeftRadius: 9999, borderBottomLeftRadius: 9999, boxShadow: `0 10px 30px ${ring(BRAND)}` }}
>
<span className="relative inline-flex">
<ClipboardList size={18} />
<span className="absolute -top-2.5 -right-2.5 min-w-[17px] h-[17px] px-1 rounded-full bg-rose-500 text-[9px] font-black flex items-center justify-center ring-2 ring-white">{selected.size}</span>
</span>
<span className="max-w-0 overflow-hidden group-hover:max-w-[80px] transition-all duration-200 whitespace-nowrap">Review</span>
</button>,
document.body,
)}
{showSelected &&
createPortal(
<SelectedOrdersPage
rows={selectedRows}
rowKey={rowKey}
riderOptions={riderOptions}
ridersLoading={ridersQ.isLoading}
assignRiderId={assignRiderId}
setAssignRiderId={setAssignRiderId}
assigning={assignMut.isPending}
assignMsg={assignMsg}
onAssign={handleAssign}
onRemove={(k) => toggleRow(k)}
onClose={() => setShowSelected(false)}
/>,
document.body,
)}
</div>
);
}
const DIVIDER_C = '#f1f5f9';
// ── Selected-orders review page (opened from the right-edge floating badge) ──────
function SelectedOrdersPage({
rows, rowKey, riderOptions, ridersLoading, assignRiderId, setAssignRiderId, assigning, assignMsg, onAssign, onRemove, onClose,
}: {
rows: Row[];
rowKey: (r: Row) => string;
riderOptions: { id: number; label: string }[];
ridersLoading: boolean;
assignRiderId: number;
setAssignRiderId: (n: number) => void;
assigning: boolean;
assignMsg: string;
onAssign: () => void;
onRemove: (k: string) => void;
onClose: () => void;
}) {
return (
<div className="fixed inset-0 z-[200] overflow-y-auto animate-in fade-in duration-200" style={{ background: '#f8fafc' }}>
{/* Sticky page header with the assign controls */}
<div className="sticky top-0 z-10 border-b" style={{ background: '#fff', borderColor: BORDER }}>
<div className="max-w-5xl mx-auto px-4 md:px-8 py-4 flex flex-wrap items-center gap-3">
<button onClick={onClose} className="inline-flex items-center gap-1.5 rounded-full font-bold text-xs cursor-pointer" style={{ padding: '8px 14px', border: `1px solid ${BORDER}`, color: TEXT_2, background: '#fff' }}>
<ArrowLeft size={14} /> Back to orders
</button>
<div className="flex items-center gap-2">
<span className="h-9 w-9 rounded-xl flex items-center justify-center" style={{ background: tint(BRAND), color: BRAND }}><ClipboardList size={18} /></span>
<div>
<h1 className="font-bold text-lg tracking-tight leading-none" style={{ color: TEXT }}>Selected Orders</h1>
<p className="text-[11px] mt-1" style={{ color: TEXT_2 }}>{rows.length} order{rows.length === 1 ? '' : 's'} ready to assign</p>
</div>
</div>
<div className="flex items-center gap-2 ml-auto">
<select value={assignRiderId} onChange={(e) => setAssignRiderId(Number(e.target.value))} className="rounded-full font-bold text-xs outline-none cursor-pointer" style={{ padding: '8px 12px', border: `1.5px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 260 }}>
<option value={0}>{ridersLoading ? 'Loading riders…' : riderOptions.length ? 'Select rider…' : 'No riders available'}</option>
{riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<button onClick={onAssign} disabled={!assignRiderId || rows.length === 0 || assigning} className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed" style={{ padding: '8px 16px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}>
{assigning ? <Loader2 size={14} className="animate-spin" /> : <UserCheck size={14} />} Assign rider
</button>
</div>
</div>
</div>
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6">
{assignMsg && <div className="mb-4 rounded-xl px-4 py-2.5 text-xs font-semibold" style={{ background: tint(BRAND), border: `1px solid ${edge(BRAND)}`, color: BRAND }}>{assignMsg}</div>}
{rows.length === 0 ? (
<div className="bg-white border rounded-2xl p-12 text-center text-xs" style={{ borderColor: BORDER, color: TEXT_3 }}>
No orders selected. <button onClick={onClose} className="font-bold underline cursor-pointer" style={{ color: BRAND }}>Go back</button>
</div>
) : (
<div className="bg-white border overflow-hidden" style={{ borderColor: BORDER }}>
<div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 720 }}>
<thead><tr>{['#', 'Order', 'Pickup', 'Drop', 'Status', ''].map((h, i) => <th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>)}</tr></thead>
<tbody>
{rows.map((r, i) => {
const st = fstr(r.orderstatus).toLowerCase();
return (
<tr key={rowKey(r) || i} style={{ borderBottom: `1px solid ${DIVIDER_C}` }}>
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td>
<td className="px-3 py-2.5">
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
</td>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[180px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
</td>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[180px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
</td>
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
<td className="px-3 py-2.5 text-right">
<button onClick={() => onRemove(rowKey(r))} title="Remove from selection" className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={15} /></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}
function TotalChip({ label, value, color }: { label: string; value: string; color: string }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full font-bold" style={{ padding: '4px 11px', fontSize: 11.5, background: soft(color), color, border: `1px solid ${edge(color)}` }}>
<span className="uppercase tracking-wider text-[9px] font-extrabold opacity-80">{label}</span>
<span className="font-mono">{value}</span>
</span>
);
}
function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) {
return (
<button onClick={onClick} disabled={disabled}
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
{children}
</button>
);
}
// ── Order details modal ─────────────────────────────────────────────────────────
function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void }) {
const orderheaderid = order.orderheaderid ?? order.orderid;
const detailsQ = useFiestaOrderDetails(orderheaderid as number | string);
const lines = (detailsQ.data ?? []).map((row) => {
const quantity = fnum(row.quantity) || fnum(row.qty) || fnum(row.orderqty);
const price = fnum(row.price) || fnum(row.unitprice) || fnum(row.retailprice);
return { name: fstr(row.productname) || fstr(row.itemname) || 'Item', quantity, price, lineTotal: fnum(row.amount) || fnum(row.productsumprice) || price * quantity };
});
const st = fstr(order.orderstatus).toLowerCase();
const total = fnum(order.deliveryamt) || fnum(order.orderamount);
// Portal to <body> so the overlay escapes any transformed / blurred / overflow
// ancestor in the view tree — otherwise `fixed inset-0` resolves against that
// ancestor (not the viewport) and the panel collapses to a sliver. The explicit
// viewport-relative width is a belt-and-suspenders so sizing never depends on
// percentage resolution against a broken containing block.
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }}
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, ${BRAND} 0%, ${soft(BRAND)} 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 }}><Package size={16} style={{ color: BRAND }} /> Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}</h4>
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
</div>
<div className="p-4 space-y-4 overflow-y-auto flex-1">
<div className="flex items-center justify-between">
<StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} />
<span className="text-[11px] font-medium" style={{ color: TEXT_2 }}>{shortTime(order.orderdate || order.deliverydate)}</span>
</div>
<div className="p-3 rounded-xl space-y-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
<div className="flex items-center gap-2 font-bold" style={{ color: TEXT }}>{fstr(order.deliverycustomer) || 'Customer'}</div>
{fstr(order.deliverycontactno) && <div className="flex items-center gap-2 font-mono text-xs" style={{ color: TEXT_2 }}><Phone size={12} /> {fstr(order.deliverycontactno)}</div>}
<div className="flex items-start gap-2 text-xs" style={{ color: TEXT_2 }}><MapPin size={12} className="mt-0.5 shrink-0" /> <span className="leading-relaxed">{fstr(order.deliveryaddress) || fstr(order.deliverysuburb) || 'Address unavailable'}</span></div>
</div>
<div>
<span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2" style={{ color: TEXT_2 }}>Order Items</span>
<div className="rounded-xl p-3" style={{ background: 'rgba(248,250,252,0.6)', border: `1px solid ${BORDER}` }}>
{detailsQ.isLoading && <div className="py-2 flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_3 }}><Loader2 size={12} className="animate-spin" /> Loading line items</div>}
{!detailsQ.isLoading && lines.length === 0 && <div className="py-2 text-[11px] font-medium" style={{ color: TEXT_3 }}>No line items returned for this order.</div>}
{lines.map((item, idx) => (
<div key={idx} className="py-2 flex justify-between items-center" style={{ borderTop: idx ? `1px solid ${DIVIDER_C}` : undefined }}>
<div><p className="font-bold text-xs" style={{ color: TEXT }}>{item.name}</p><p className="text-[10px]" style={{ color: TEXT_2 }}>Qty: {item.quantity} × {item.price}</p></div>
<span className="font-extrabold font-mono text-xs" style={{ color: TEXT }}>{item.lineTotal.toLocaleString('en-IN')}</span>
</div>
))}
{total > 0 && (
<div className="pt-2 mt-1 flex justify-between items-center font-extrabold text-sm" style={{ color: BRAND, borderTop: `1px dashed ${BORDER}` }}>
<span>Order Total</span><span className="font-mono">{total.toLocaleString('en-IN')}</span>
</div>
)}
</div>
</div>
</div>
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT_LOCAL})` }}>Close</button>
</div>
</div>
</div>,
document.body,
);
}
const BRAND_LIGHT_LOCAL = '#9255AB';