update on the user page regardinga the dispatch and order page and the deliveries page
This commit is contained in:
301
src/components/OrdersView.tsx
Normal file
301
src/components/OrdersView.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* @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 } 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 { 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,
|
||||
} from './consoleUi';
|
||||
|
||||
interface OrdersViewProps {
|
||||
searchQuery?: string;
|
||||
locationid?: number;
|
||||
}
|
||||
|
||||
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 }: 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 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) },
|
||||
];
|
||||
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 [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 });
|
||||
const summary = summaryQ.data;
|
||||
const rawRows = ordersQ.data ?? [];
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const term = (localSearch || searchQuery).toLowerCase();
|
||||
return rawRows.filter((r) => {
|
||||
if (locationid && fnum(r.locationid) !== locationid) 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)
|
||||
);
|
||||
});
|
||||
}, [rawRows, localSearch, searchQuery, locationid]);
|
||||
|
||||
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);
|
||||
|
||||
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` },
|
||||
];
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<GradientHeader
|
||||
title="Orders"
|
||||
subtitle="Live order board across the lifecycle — created, pending, processing, delivered, and cancelled."
|
||||
status={
|
||||
ordersQ.isLoading
|
||||
? <LiveStatus state="loading" label="Loading live orders…" />
|
||||
: ordersQ.isError
|
||||
? <LiveStatus state="error" label="Live data unavailable" />
|
||||
: <LiveStatus state="live" label={`Live · ${total.toLocaleString('en-IN')} orders in range`} />
|
||||
}
|
||||
right={
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full font-extrabold" style={{ padding: '6px 12px', fontSize: 12, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}>
|
||||
<MapPin size={13} /> Coimbatore
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4"><KpiStrip items={kpis} loading={summaryQ.isLoading} /></div>
|
||||
|
||||
{/* Date filter */}
|
||||
<FilterBar className="mb-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-extrabold uppercase tracking-widest pr-1" style={{ color: TEXT_2 }}>
|
||||
<Calendar size={13} style={{ color: BRAND }} /> View
|
||||
</span>
|
||||
{presets.map((p) => (
|
||||
<React.Fragment key={p.key}>
|
||||
<Pill active={activePreset === p.key} color={BRAND} onClick={() => setScope({ from: p.from, to: p.to })}>{p.label}</Pill>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<input type="date" value={fromdate} max={todate} onChange={(e) => setScope({ from: e.target.value })}
|
||||
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||
<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' }} />
|
||||
</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 py-0.5 flex-1 min-w-0">
|
||||
{STATUS_TABS.map((t) => {
|
||||
const color = statusColor(ORDER_STATUS, t.key);
|
||||
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="w-full lg:w-72 lg:shrink-0"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders…" /></div>
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{/* 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 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 }}>
|
||||
<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>
|
||||
) : (
|
||||
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')}>
|
||||
<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">
|
||||
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
|
||||
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
|
||||
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td>
|
||||
<td className="px-3 py-2.5">{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"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
|
||||
<td className="px-3 py-2.5 text-right">
|
||||
<button onClick={() => setDetailOrder(r)} className="rounded-full font-extrabold cursor-pointer transition-colors"
|
||||
style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>View</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}>Page {pageno} · {rows.length} shown</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<PagerBtn disabled={pageno === 1} onClick={() => setPageno((p) => Math.max(1, p - 1))}><ChevronLeft size={13} /> Prev</PagerBtn>
|
||||
<PagerBtn disabled={!hasNext} onClick={() => setPageno((p) => p + 1)}>Next <ChevronRight size={13} /></PagerBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DIVIDER_C = '#f1f5f9';
|
||||
|
||||
function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Order details modal ─────────────────────────────────────────────────────────
|
||||
function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void }) {
|
||||
const orderheaderid = order.orderheaderid ?? order.orderid;
|
||||
const detailsQ = useFiestaOrderDetails(orderheaderid as number | string);
|
||||
const lines = (detailsQ.data ?? []).map((row) => {
|
||||
const quantity = fnum(row.quantity) || fnum(row.qty) || fnum(row.orderqty);
|
||||
const price = fnum(row.price) || fnum(row.unitprice) || fnum(row.retailprice);
|
||||
return { name: fstr(row.productname) || fstr(row.itemname) || 'Item', quantity, price, lineTotal: fnum(row.amount) || fnum(row.productsumprice) || price * quantity };
|
||||
});
|
||||
const st = fstr(order.orderstatus).toLowerCase();
|
||||
const total = fnum(order.deliveryamt) || fnum(order.orderamount);
|
||||
|
||||
return (
|
||||
<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 style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${soft(BRAND)} 100%)` }} />
|
||||
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Package size={16} style={{ color: BRAND }} /> Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}</h4>
|
||||
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4 overflow-y-auto flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} />
|
||||
<span className="text-[11px] font-medium" style={{ color: TEXT_2 }}>{shortTime(order.orderdate || order.deliverydate)}</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl space-y-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
||||
<div className="flex items-center gap-2 font-bold" style={{ color: TEXT }}>{fstr(order.deliverycustomer) || 'Customer'}</div>
|
||||
{fstr(order.deliverycontactno) && <div className="flex items-center gap-2 font-mono text-xs" style={{ color: TEXT_2 }}><Phone size={12} /> {fstr(order.deliverycontactno)}</div>}
|
||||
<div className="flex items-start gap-2 text-xs" style={{ color: TEXT_2 }}><MapPin size={12} className="mt-0.5 shrink-0" /> <span className="leading-relaxed">{fstr(order.deliveryaddress) || fstr(order.deliverysuburb) || 'Address unavailable'}</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2" style={{ color: TEXT_2 }}>Order Items</span>
|
||||
<div className="rounded-xl p-3" style={{ background: 'rgba(248,250,252,0.6)', border: `1px solid ${BORDER}` }}>
|
||||
{detailsQ.isLoading && <div className="py-2 flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_3 }}><Loader2 size={12} className="animate-spin" /> Loading line items…</div>}
|
||||
{!detailsQ.isLoading && lines.length === 0 && <div className="py-2 text-[11px] font-medium" style={{ color: TEXT_3 }}>No line items returned for this order.</div>}
|
||||
{lines.map((item, idx) => (
|
||||
<div key={idx} className="py-2 flex justify-between items-center" style={{ borderTop: idx ? `1px solid ${DIVIDER_C}` : undefined }}>
|
||||
<div><p className="font-bold text-xs" style={{ color: TEXT }}>{item.name}</p><p className="text-[10px]" style={{ color: TEXT_2 }}>Qty: {item.quantity} × ₹{item.price}</p></div>
|
||||
<span className="font-extrabold font-mono text-xs" style={{ color: TEXT }}>₹{item.lineTotal.toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
))}
|
||||
{total > 0 && (
|
||||
<div className="pt-2 mt-1 flex justify-between items-center font-extrabold text-sm" style={{ color: BRAND, borderTop: `1px dashed ${BORDER}` }}>
|
||||
<span>Order Total</span><span className="font-mono">₹{total.toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT_LOCAL})` }}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BRAND_LIGHT_LOCAL = '#9255AB';
|
||||
Reference in New Issue
Block a user