Files
daily_merchant_web/src/components/OrdersView.tsx
2026-08-14 15:28:06 +05:30

901 lines
46 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, 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 {
KpiStrip, Pill, StatusChip, SearchPill, FilterBar, Card as ShellCard,
ORDER_STATUS, statusColor, BRAND, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge, ring,
} from './consoleUi';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { AppDialog } from './AppDialog';
import { DateInput } from '@astryxdesign/core/DateInput';
// DateInput's value is a branded `YYYY-MM-DD` template-literal type, while our
// dates are plain strings out of `ymd()`. Importing the real type keeps the cast
// honest and localised instead of reaching for `as any` at four call sites.
import type { ISODateString } from '@astryxdesign/core/utils';
import { Selector } from '@astryxdesign/core/Selector';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Heading } from '@astryxdesign/core/Heading';
import { Spinner } from '@astryxdesign/core/Spinner';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
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>
{/* PHASE 6 — two bare `<input type="date">` become DateInputs. Both
were unlabelled, so a screen reader announced only "date"; and the
native picker was the only way in, with no keyboard entry format
hint. The min/max cross-guard between the two is preserved. */}
<div className="flex items-center gap-2 text-xs">
<DateInput
label="From date"
isLabelHidden
size="sm"
value={fromdate as ISODateString}
max={todate as ISODateString}
onChange={(v) => { if (v) setScope({ from: v }); }}
/>
<span style={{ color: TEXT_3 }}>→</span>
<DateInput
label="To date"
isLabelHidden
size="sm"
value={todate as ISODateString}
min={fromdate as ISODateString}
onChange={(v) => { if (v) setScope({ to: v }); }}
/>
</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 && (
<div className="shrink-0 min-w-[150px]">
<Selector
label="Filter by branch"
isLabelHidden
size="sm"
value={String(branch)}
onChange={(v) => { setBranch(Number(v) || 0); setPageno(1); }}
options={[
{ value: '0', label: 'All branches' },
...branches.map((b) => ({ value: String(b.id), label: b.name })),
]}
/>
</div>
)}
<div className="w-full lg:w-60"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders (Ctrl+K)…" inputRef={searchRef} /></div>
<Button
label="CSV"
size="sm"
icon={<Download size={13} />}
isDisabled={rows.length === 0}
onClick={exportCsv}
/>
</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>
{/* PHASE 6 — the two-button rider-source toggle is a SegmentedControl.
It was a pair of buttons that looked like a segmented control but
announced as two unrelated buttons with no indication which was
active; the real one exposes the selection to assistive tech. */}
<div className="ml-2">
<SegmentedControl
label="Rider source"
size="sm"
value={riderSource}
onChange={(v) => setRiderSource(v as 'all' | 'partner')}
>
<SegmentedControlItem value="all" label="On Duty" />
<SegmentedControlItem value="partner" label="This Partner" isDisabled={!orderPartnerId} />
</SegmentedControl>
</div>
{/* The empty-list wording carried real information — which of the two
scopes came back empty — so it moves to `placeholder` rather than
being flattened into a generic "Select…". */}
<div className="min-w-[200px] max-w-[260px]">
<Selector
label="Choose a rider to assign"
isLabelHidden
size="sm"
value={assignRiderId ? String(assignRiderId) : ''}
onChange={(v) => setAssignRiderId(Number(v) || 0)}
isDisabled={selected.size === 0}
isLoading={ridersQ.isLoading}
placeholder={
ridersQ.isLoading
? 'Loading riders…'
: riderOptions.length
? 'Select rider…'
: riderSource === 'partner'
? 'No riders on duty for this partner'
: 'No riders on duty right now'
}
options={riderOptions.map((o) => ({ value: String(o.id), label: o.label }))}
/>
</div>
<Button
label="Assign rider"
size="sm"
icon={<UserCheck size={13} />}
isLoading={assignMut.isPending}
isDisabled={!assignRiderId || selected.size === 0 || assignMut.isPending}
onClick={handleAssign}
/>
{selected.size > 0 && (
<Button label="Clear" variant="secondary" size="sm" onClick={() => setSelected(new Set())} />
)}
{assignMsg && <span className="text-[11px] font-semibold ml-auto" style={{ color: TEXT_2 }}>{assignMsg}</span>}
</div>
)}
{/* Table
PHASE 6 — raw <table> onto the Astryx primitives, and the three bare
`<input type="checkbox">` onto CheckboxInput. The row boxes carried an
aria-label but no visible label and no focus ring beyond the browser
default; the disabled one explained itself only through `title`, which
a keyboard user never sees. */}
<ShellCard className="overflow-hidden">
<div className="overflow-x-auto">
<Table style={{ minWidth: 960 }}>
<TableHeader>
<TableRow>
<TableHeaderCell>
<CheckboxInput
label="Select all assignable orders"
isLabelHidden
value={allSelected}
isDisabled={assignableRows.length === 0}
onChange={toggleAll}
/>
</TableHeaderCell>
{['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'KMs', 'Charges', 'Status', ''].map((h, i) => (
<TableHeaderCell key={i}>{h}</TableHeaderCell>
))}
</TableRow>
</TableHeader>
<TableBody>
{ordersQ.isLoading ? (
<TableRow><TableCell colSpan={12}>
<div className="py-8 flex justify-center"><Spinner label="Loading orders…" /></div>
</TableCell></TableRow>
) : rows.length === 0 ? (
<TableRow><TableCell colSpan={12}>
<EmptyState
isCompact
icon={<Package size={26} />}
title="No orders found"
description="Nothing matches this status, date range, or search."
/>
</TableCell></TableRow>
) : (
rows.map((r, i) => {
const st = fstr(r.orderstatus).toLowerCase();
const cod = fnum(r.collectionamt);
const charges = fnum(r.deliverycharge) || fnum(r.deliverycharges);
const isSel = selected.has(rowKey(r));
return (
// `rowKey`, not `orderid`. `orderid` is NOT unique — this
// tenant returns several rows sharing one (three "1135-1" on
// the store console alone), so React logged a duplicate-key
// error for most of the page and was free to duplicate or
// drop rows. `rowKey` prefers `orderheaderid`, the actual
// primary key, and is already what the selection Set uses —
// so row identity and checkbox identity now agree instead of
// drifting apart on re-render.
//
// The selected tint stays inline: it is a brand wash keyed to
// selection state, which Table has no prop for.
<TableRow key={rowKey(r) || i} style={isSel ? { background: tint(BRAND) } : undefined}>
<TableCell>
{st === 'created' ? (
<CheckboxInput
label="Select order"
isLabelHidden
value={isSel}
onChange={() => toggleRow(rowKey(r))}
/>
) : (
<CheckboxInput
label="Only 'created' orders can be assigned"
isLabelHidden
value={false}
isDisabled
/>
)}
</TableCell>
<TableCell><span className="font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</span></TableCell>
<TableCell>
<p className="font-extrabold font-mono text-[13px]">{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
</TableCell>
<TableCell>
<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>}
</TableCell>
<TableCell>
<p className="font-bold text-[12px] truncate max-w-[150px]">{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>
</TableCell>
<TableCell>
<p className="font-bold text-[12px] truncate max-w-[150px]">{fstr(r.deliverycustomer) || '—'}</p>
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
</TableCell>
<TableCell><span className="font-mono text-[12px]">{fnum(r.quantity) || '—'}</span></TableCell>
<TableCell><span className="font-mono text-[12px] font-semibold" style={cod > 0 ? undefined : { color: TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'}</span></TableCell>
<TableCell><span className="font-mono text-[12px]" style={{ color: fnum(r.kms) ? TEXT_2 : TEXT_3 }}>{fnum(r.kms) ? fnum(r.kms).toFixed(1) : '—'}</span></TableCell>
<TableCell><span className="font-mono text-[12px] font-semibold" style={charges > 0 ? undefined : { color: TEXT_3 }}>{charges > 0 ? `₹${charges.toLocaleString('en-IN')}` : '—'}</span></TableCell>
<TableCell><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></TableCell>
<TableCell>
<div className="text-right">
<Button label="View" variant="secondary" size="sm" onClick={() => setDetailOrder(r)} />
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</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>
{/* Astryx `Pagination` wants a known page count; this list is
cursor-shaped (hasNext is inferred from a full page), so Prev/Next
stay two Buttons rather than a pager that would have to invent a
total it does not have. */}
<div className="flex items-center gap-2">
<Button
label="Prev"
variant="secondary"
size="sm"
icon={<ChevronLeft size={13} />}
isDisabled={pageno === 1}
onClick={() => setPageno((p) => Math.max(1, p - 1))}
/>
<Button
label="Next"
variant="secondary"
size="sm"
endContent={<ChevronRight size={13} />}
isDisabled={!hasNext}
onClick={() => setPageno((p) => p + 1)}
/>
</div>
</div>
</ShellCard>
{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(
// PHASE 6 — the label used to be collapsed to `max-w-0` and revealed
// only on hover, so the button read as an unlabelled icon until you
// pointed at it and was invisible to anyone not using a mouse. The
// Astryx Button carries the count in its label instead.
<div className="fixed right-4 z-[150] animate-in slide-in-from-right-4" style={{ top: '70%' }}>
<Button
label={`Review ${selected.size} selected`}
icon={<ClipboardList size={16} />}
onClick={() => setShowSelected(true)}
/>
</div>,
document.body,
)}
{/* No createPortal any more — Dialog renders in the browser's top layer,
which is already outside every ancestor's containing block. */}
{showSelected && (
<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)}
/>
)}
</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 (
// PHASE 6 — was a `fixed inset-0` page takeover with no Escape and no focus
// trap: tabbing from it walked straight back into the orders board
// underneath. Dialog's fullscreen variant gives both, and keeps the same
// full-bleed shape.
//
// What it does NOT give is focus restore — verified in Chrome: closing this
// leaves focus on <body>, not on the control that opened it. consoleUi's
// SlideDrawer restores it because it does so by hand. That gap is Astryx's,
// it applies to every Dialog in the app, and it is a missing gain rather
// than a regression (the old overlay managed no focus at all), so it is not
// patched here one screen at a time.
<AppDialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} variant="fullscreen" purpose="info">
<div className="h-full overflow-y-auto">
{/* Sticky page header with the assign controls */}
<div className="sticky top-0 z-10 border-b" style={{ background: 'var(--color-background-card, #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 label="Back to orders" variant="secondary" size="sm" icon={<ArrowLeft size={14} />} onClick={onClose} />
<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>
<Heading level={2}>Selected Orders</Heading>
<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">
<div className="min-w-[200px] max-w-[260px]">
<Selector
label="Choose a rider to assign"
isLabelHidden
size="sm"
value={assignRiderId ? String(assignRiderId) : ''}
onChange={(v) => setAssignRiderId(Number(v) || 0)}
isLoading={ridersLoading}
placeholder={ridersLoading ? 'Loading riders…' : riderOptions.length ? 'Select rider…' : 'No riders available'}
options={riderOptions.map((o) => ({ value: String(o.id), label: o.label }))}
/>
</div>
<Button
label="Assign rider"
size="sm"
icon={<UserCheck size={14} />}
isLoading={assigning}
isDisabled={!assignRiderId || rows.length === 0 || assigning}
onClick={onAssign}
/>
</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 ? (
<ShellCard className="p-12">
<EmptyState
title="No orders selected"
actions={<Button label="Go back" variant="secondary" onClick={onClose} />}
/>
</ShellCard>
) : (
<ShellCard className="overflow-hidden">
<div className="overflow-x-auto">
<Table style={{ minWidth: 720 }}>
<TableHeader>
<TableRow>
{['#', 'Order', 'Pickup', 'Drop', 'Status', ''].map((h, i) => <TableHeaderCell key={i}>{h}</TableHeaderCell>)}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r, i) => {
const st = fstr(r.orderstatus).toLowerCase();
return (
<TableRow key={rowKey(r) || i}>
<TableCell><span className="font-mono" style={{ color: TEXT_3 }}>{i + 1}</span></TableCell>
<TableCell>
<p className="font-extrabold font-mono text-[13px]">{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
</TableCell>
<TableCell>
<p className="font-bold text-[12px] truncate max-w-[180px]">{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>
</TableCell>
<TableCell>
<p className="font-bold text-[12px] truncate max-w-[180px]">{fstr(r.deliverycustomer) || '—'}</p>
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
</TableCell>
<TableCell><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></TableCell>
<TableCell>
<div className="text-right">
<IconButton
label={`Remove order ${fstr(r.orderid) || fstr(r.orderheaderid)} from selection`}
variant="ghost"
size="sm"
icon={<X size={15} />}
onClick={() => onRemove(rowKey(r))}
/>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</ShellCard>
)}
</div>
</div>
</AppDialog>
);
}
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>
);
}
// ── 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);
// PHASE 6 — the hand-rolled overlay is gone, and with it the portal and the
// `width: min(32rem, 92vw)` workaround. Both existed for one reason: a
// `fixed inset-0` resolves against the nearest transformed/filtered ancestor
// rather than the viewport, and this page has several, so the panel used to
// collapse to a sliver. Dialog renders in the browser's top layer, which no
// ancestor can contain — so the escape hatches are no longer needed.
return (
<AppDialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
<div className="flex flex-col max-h-[90vh]">
<div className="p-4 flex justify-between items-center shrink-0">
<Heading level={3}>
<span className="flex items-center gap-2">
<Package size={16} style={{ color: BRAND }} /> Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}
</span>
</Heading>
<IconButton label="Close" variant="ghost" size="sm" icon={<X size={16} />} onClick={onClose} />
</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: 'rgba(127,127,127,0.06)' }}>
<div className="flex items-center gap-2 font-bold">{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(127,127,127,0.04)' }}>
{detailsQ.isLoading && <div className="py-2 flex justify-center"><Spinner size="sm" label="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">{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">₹{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 flex justify-end shrink-0">
<Button label="Close" onClick={onClose} />
</div>
</div>
</AppDialog>
);
}