901 lines
46 KiB
TypeScript
901 lines
46 KiB
TypeScript
/**
|
||
* @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>
|
||
);
|
||
}
|