dispatch page
This commit is contained in:
@@ -11,19 +11,22 @@
|
||||
* to the live Fiesta order endpoints (status-scoped, date-ranged, paginated).
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2 } from 'lucide-react';
|
||||
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails } from '../services/fiestaQueries';
|
||||
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 } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, 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, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge,
|
||||
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;
|
||||
}
|
||||
|
||||
type StatusKey = 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled';
|
||||
@@ -36,57 +39,167 @@ const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [
|
||||
];
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export default function OrdersView({ searchQuery = '', locationid }: OrdersViewProps) {
|
||||
export default function OrdersView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID }: OrdersViewProps) {
|
||||
const today = new Date();
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const [fromdate, setFromdate] = useState<string>(ymd(today));
|
||||
const [todate, setTodate] = useState<string>(ymd(today));
|
||||
|
||||
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: '30d', label: 'Last 30 Days', from: dayOffset(29), to: ymd(today) },
|
||||
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: ymd(today) },
|
||||
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
||||
{ 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);
|
||||
|
||||
const summaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, fromdate, todate);
|
||||
const ordersQ = useFiestaOrders({ tenantid: FIESTA_TENANT_ID, status, fromdate, todate, pageno, pagesize: PAGE_SIZE });
|
||||
// ── 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();
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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 must share the orders' tenant + partner to be assignable (the backend
|
||||
// rejects cross-tenant/partner riders), so derive the partner/app-location from
|
||||
// the live order rows and scope the rider list to them. An out-of-tenant rider
|
||||
// simply won't appear — the intended guard.
|
||||
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 ridersQ = useFiestaRiders({
|
||||
tenantid: tenantId,
|
||||
applocationid: orderApplocationId || undefined,
|
||||
partnerid: orderPartnerId || undefined,
|
||||
});
|
||||
const riderOptions = useMemo(
|
||||
() =>
|
||||
(ridersQ.data ?? [])
|
||||
.map((r) => ({
|
||||
id: fnum(r.userid),
|
||||
label: `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() + (fstr(r.contactno) ? ` · ${fstr(r.contactno)}` : ''),
|
||||
}))
|
||||
.filter((o) => o.id > 0 && o.label),
|
||||
[ridersQ.data],
|
||||
);
|
||||
|
||||
// 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;
|
||||
return (
|
||||
fstr(r.orderid).toLowerCase().includes(term) ||
|
||||
fstr(r.deliverycustomer).toLowerCase().includes(term) ||
|
||||
fstr(r.pickupcustomer).toLowerCase().includes(term) ||
|
||||
fstr(r.deliveryaddress).toLowerCase().includes(term) ||
|
||||
fstr(r.deliverysuburb).toLowerCase().includes(term)
|
||||
);
|
||||
// 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]);
|
||||
}, [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: '#0ea5e9', 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: '#ef4444', icon: <XCircle size={20} />, badge: `${pct(summary?.cancelled ?? 0)}% of total` },
|
||||
{ label: 'Created Orders', value: (summary?.created ?? 0).toLocaleString('en-IN'), color: '#475569', icon: <ShoppingBag size={20} />, badge: `${pct(summary?.created ?? 0)}% of total` },
|
||||
{ label: 'Pending Orders', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#9a6700', icon: <Clock size={20} />, badge: `${pct(summary?.pending ?? 0)}% of total` },
|
||||
{ label: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#15803d', icon: <CheckCircle2 size={20} />, badge: `${pct(summary?.delivered ?? 0)}% of total` },
|
||||
{ label: 'Cancelled Orders', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#b42318', icon: <XCircle size={20} />, badge: `${pct(summary?.cancelled ?? 0)}% of total` },
|
||||
];
|
||||
|
||||
const setScope = (next: Partial<{ status: StatusKey; from: string; to: string }>) => {
|
||||
@@ -96,6 +209,46 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
|
||||
setPageno(1);
|
||||
};
|
||||
|
||||
// ── Selection helpers ───────────────────────────────────────────────────────
|
||||
const rowKey = (r: Row) => fstr(r.orderheaderid) || fstr(r.orderid);
|
||||
const pageKeys = rows.map(rowKey);
|
||||
const allSelected = pageKeys.length > 0 && pageKeys.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) pageKeys.forEach((k) => n.delete(k));
|
||||
else pageKeys.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 rider = riderOptions.find((o) => o.id === assignRiderId)?.label ?? 'rider';
|
||||
try {
|
||||
const res = await assignMut.mutateAsync({ userid: assignRiderId, orders: toAssign });
|
||||
setAssignMsg(
|
||||
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
|
||||
} 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">
|
||||
<GradientHeader
|
||||
@@ -132,10 +285,10 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
|
||||
</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: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||
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} max={ymd(today)} onChange={(e) => setScope({ to: e.target.value })}
|
||||
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||
<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>
|
||||
@@ -145,7 +298,9 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0">
|
||||
{STATUS_TABS.map((t) => {
|
||||
const color = statusColor(ORDER_STATUS, t.key);
|
||||
// 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')}>
|
||||
@@ -155,41 +310,110 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="w-full lg:w-72 lg:shrink-0"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders…" /></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>
|
||||
<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…' : 'No riders available'}</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 rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth: 960 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{['#', 'Order', 'Pickup', 'Drop', 'Qty', 'COD', 'KMs', 'Charges', 'Status', ''].map((h, i) => (
|
||||
<th className="px-3 py-2.5 text-left" style={TH_STYLE}>
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleAll} disabled={rows.length === 0} aria-label="Select all 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={10} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
|
||||
<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={10} 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>
|
||||
<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}` }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
|
||||
<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">
|
||||
<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 }} />
|
||||
</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>
|
||||
@@ -199,9 +423,9 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
|
||||
<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">{cod > 0 ? <MetricPill color="#ef4444">₹{cod.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
||||
<td className="px-3 py-2.5">{fnum(r.kms) ? <MetricPill color="#ef4444">{fnum(r.kms).toFixed(1)}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
||||
<td className="px-3 py-2.5">{charges > 0 ? <MetricPill color="#10b981">₹{charges.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</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"
|
||||
@@ -214,6 +438,15 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
|
||||
</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">
|
||||
@@ -224,12 +457,148 @@ export default function OrdersView({ searchQuery = '', locationid }: OrdersViewP
|
||||
</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 rounded-2xl 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}
|
||||
@@ -252,10 +621,15 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
|
||||
const st = fstr(order.orderstatus).toLowerCase();
|
||||
const total = fnum(order.deliveryamt) || fnum(order.orderamount);
|
||||
|
||||
return (
|
||||
// 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 w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200" style={{ border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
|
||||
<div className="bg-white max-h-[90vh] flex flex-col overflow-hidden rounded-2xl 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>
|
||||
@@ -294,7 +668,8 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
|
||||
<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>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user