update on the user page regardinga the dispatch and order page and the deliveries page
This commit is contained in:
323
src/components/DeliveriesView.tsx
Normal file
323
src/components/DeliveriesView.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Deliveries page — replicated from the operations console (nearle_console/
|
||||
* deliveries), rebuilt against the shared console UI kit (`./consoleUi`) so it
|
||||
* matches the source design (gradient header, KPI cards, batch + status pill
|
||||
* tabs, STATUS_META colours, metric-pill table). The board loads the day's
|
||||
* deliveries once and filters client-side by delivery wave, lifecycle status, and
|
||||
* keyword. Rider write-actions (reassign/cancel/notify) need the dispatch + FCM
|
||||
* backends this tenant doesn't expose, so they surface an "awaiting backend" note.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike,
|
||||
} from 'lucide-react';
|
||||
import { useFiestaDeliverySummary, useFiestaDeliveries, useFiestaRiders, 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 AwaitingApi from './AwaitingApi';
|
||||
import {
|
||||
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
|
||||
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge,
|
||||
} from './consoleUi';
|
||||
|
||||
interface DeliveriesViewProps { searchQuery?: string; locationid?: number; }
|
||||
|
||||
type DeliveryStatus = 'pending' | 'accepted' | 'arrived' | 'picked' | 'active' | 'skipped' | 'delivered' | 'cancelled';
|
||||
const STATUS_TABS: Array<{ key: DeliveryStatus; label: string }> = [
|
||||
{ key: 'pending', label: 'Pending' }, { key: 'accepted', label: 'Accepted' }, { key: 'arrived', label: 'Arrived' },
|
||||
{ key: 'picked', label: 'Picked' }, { key: 'active', label: 'Active' }, { key: 'skipped', label: 'Skipped' },
|
||||
{ key: 'delivered', label: 'Delivered' }, { key: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
|
||||
// Batch waves — canonical half-open hour ranges (match Dispatch).
|
||||
type BatchId = 'all' | 'morning' | 'afternoon' | 'evening';
|
||||
const BATCHES: Array<{ id: BatchId; label: string; range: string; color: string; icon: typeof Sun }> = [
|
||||
{ id: 'all', label: 'All', range: 'Full day', color: '#7c3aed', icon: Layers },
|
||||
{ id: 'morning', label: 'Morning', range: '12 AM – 8 AM', color: '#0ea5e9', icon: Sun },
|
||||
{ id: 'afternoon', label: 'Afternoon', range: '9 AM – 12:30 PM', color: '#f59e0b', icon: Sunset },
|
||||
{ id: 'evening', label: 'Evening', range: '4 PM – 7 PM', color: '#6366f1', icon: Moon },
|
||||
];
|
||||
function rowHourFrac(r: Row): number | null {
|
||||
const m = (fstr(r.assigntime) || fstr(r.deliverytime) || fstr(r.deliverydate)).match(/[ T](\d{1,2}):(\d{2})/);
|
||||
return m ? Number(m[1]) + Number(m[2]) / 60 : null;
|
||||
}
|
||||
function inBatch(r: Row, b: BatchId): boolean {
|
||||
if (b === 'all') return true;
|
||||
const h = rowHourFrac(r);
|
||||
if (h == null) return false;
|
||||
if (b === 'morning') return h >= 0 && h < 8;
|
||||
if (b === 'afternoon') return h >= 9 && h < 12.5;
|
||||
return h >= 16 && h < 19;
|
||||
}
|
||||
function initialBatch(): BatchId {
|
||||
const h = new Date().getHours();
|
||||
if (h >= 0 && h < 8) return 'morning';
|
||||
if (h >= 9 && h < 12.5) return 'afternoon';
|
||||
if (h >= 16 && h < 19) return 'evening';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
export default function DeliveriesView({ searchQuery = '', locationid }: DeliveriesViewProps) {
|
||||
const today = new Date();
|
||||
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) },
|
||||
];
|
||||
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
||||
|
||||
const [batch, setBatch] = useState<BatchId>(initialBatch());
|
||||
const [status, setStatus] = useState<DeliveryStatus>('pending');
|
||||
const [localSearch, setLocalSearch] = useState('');
|
||||
const [detailRow, setDetailRow] = useState<Row | null>(null);
|
||||
|
||||
const summaryQ = useFiestaDeliverySummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
||||
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
||||
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
|
||||
|
||||
const allRows = deliveriesQ.data ?? [];
|
||||
const summary = summaryQ.data;
|
||||
|
||||
const batchRows = useMemo(() => allRows.filter((r) => (!locationid || fnum(r.locationid) === locationid) && inBatch(r, batch)), [allRows, batch, locationid]);
|
||||
const statusCounts = useMemo(() => {
|
||||
const acc: Record<string, number> = {};
|
||||
for (const r of batchRows) { const s = fstr(r.orderstatus).toLowerCase(); acc[s] = (acc[s] ?? 0) + 1; }
|
||||
return acc;
|
||||
}, [batchRows]);
|
||||
const rows = useMemo(() => {
|
||||
const term = (localSearch || searchQuery).toLowerCase();
|
||||
return batchRows.filter((r) => {
|
||||
if (fstr(r.orderstatus).toLowerCase() !== status) return false;
|
||||
if (!term) return true;
|
||||
return [r.orderid, r.deliverycustomer, r.deliveryaddress, r.deliverysuburb, r.pickupcustomer, r.ridername, r.username].some((f) => fstr(f).toLowerCase().includes(term));
|
||||
});
|
||||
}, [batchRows, status, localSearch, searchQuery]);
|
||||
|
||||
const activeFleet = (ridersQ.data ?? []).filter((r) => fstr(r.starttime)).length;
|
||||
const total = summary?.total ?? 0;
|
||||
const pct = (n: number) => (total > 0 ? `${Math.round((n / total) * 100)}% of total` : 'In range');
|
||||
const kpis = [
|
||||
{ label: 'Total Deliveries', value: total.toLocaleString('en-IN'), color: '#6366f1', icon: <Truck size={20} />, badge: undefined },
|
||||
{ label: 'Pending', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#f59e0b', icon: <Clock size={20} />, badge: pct(summary?.pending ?? 0) },
|
||||
{ label: 'Delivered', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} />, badge: pct(summary?.delivered ?? 0) },
|
||||
{ label: 'Cancelled', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#ef4444', icon: <XCircle size={20} />, badge: pct(summary?.cancelled ?? 0) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<GradientHeader
|
||||
title="Deliveries"
|
||||
subtitle="Dispatch board for in-transit orders — tracked across the rider lifecycle and grouped into delivery waves."
|
||||
status={
|
||||
deliveriesQ.isLoading ? <LiveStatus state="loading" label="Loading live deliveries…" />
|
||||
: deliveriesQ.isError ? <LiveStatus state="error" label="Live data unavailable" />
|
||||
: <LiveStatus state="live" label={`Live · ${batchRows.length} in this wave · ${activeFleet} riders on duty`} />
|
||||
}
|
||||
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 + waves */}
|
||||
<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={() => { setFromdate(p.from); setTodate(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) => setFromdate(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) => setTodate(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>
|
||||
<div className="flex items-center gap-2 flex-wrap pt-3 mt-3 border-t" style={{ borderColor: DIVIDER }}>
|
||||
<span className="text-[10px] font-extrabold uppercase tracking-widest pr-1" style={{ color: TEXT_2 }}>Wave</span>
|
||||
{BATCHES.map((b) => {
|
||||
const Icon = b.icon;
|
||||
const count = allRows.filter((r) => (!locationid || fnum(r.locationid) === locationid) && inBatch(r, b.id)).length;
|
||||
return (
|
||||
<React.Fragment key={b.id}>
|
||||
<Pill active={batch === b.id} color={b.color} onClick={() => setBatch(b.id)} title={b.range} count={count}><Icon size={13} /> {b.label}</Pill>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</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(DELIVERY_STATUS, t.key);
|
||||
return (
|
||||
<React.Fragment key={t.key}>
|
||||
<Pill active={status === t.key} color={color} onClick={() => setStatus(t.key)} count={statusCounts[t.key] ?? 0}>{t.label}</Pill>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="w-full lg:w-72 lg:shrink-0"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search by order, rider…" color="#6366f1" /></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: 1040 }}>
|
||||
<thead>
|
||||
<tr>{['#', 'Status', 'Order', 'Drop', 'Rider', 'ETA', 'KMs', 'Amount', ''].map((h, i) => (<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>))}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deliveriesQ.isLoading ? (
|
||||
<tr><td colSpan={9} 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 deliveries…</span></td></tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr><td colSpan={9} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No {status} deliveries in this wave. Try another status, wave, or date.</td></tr>
|
||||
) : (
|
||||
rows.map((r, i) => {
|
||||
const st = fstr(r.orderstatus).toLowerCase();
|
||||
const rider = fstr(r.ridername) || fstr(r.username);
|
||||
const kms = fnum(r.kms); const actualKms = fnum(r.cumulativekms);
|
||||
const charge = fnum(r.deliverycharges); const amt = fnum(r.deliveryamt);
|
||||
return (
|
||||
<tr key={fstr(r.deliveryid) || fstr(r.orderid) || i} className="transition-colors align-top" style={{ borderBottom: `1px solid ${DIVIDER}` }}
|
||||
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 }}>{i + 1}</td>
|
||||
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}</p>
|
||||
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.assigntime || r.deliverydate)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
|
||||
<p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{rider ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="rounded-full flex items-center justify-center shrink-0" style={{ width: 26, height: 26, background: soft('#8b5cf6'), color: '#8b5cf6' }}><Bike size={13} /></span>
|
||||
<span className="font-bold text-[12px] truncate max-w-[100px]" style={{ color: TEXT }}>{rider}</span>
|
||||
</span>
|
||||
) : <span className="text-[11px] italic" style={{ color: TEXT_3 }}>Unassigned</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><MetricPill color="#06b6d4">{shortTime(r.expecteddeliverytime) || '—'}</MetricPill></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<MetricPill color="#ef4444" minWidth={64}>{kms ? kms.toFixed(1) : '—'}</MetricPill>
|
||||
{actualKms > 0 && <MetricPill color="#10b981" minWidth={64}>{actualKms.toFixed(1)}</MetricPill>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{charge > 0 && <MetricPill color="#ef4444" minWidth={72}>₹{charge.toLocaleString('en-IN')}</MetricPill>}
|
||||
{amt > 0 && <MetricPill color="#10b981" minWidth={72}>₹{amt.toLocaleString('en-IN')}</MetricPill>}
|
||||
{charge === 0 && amt === 0 && <span style={{ color: TEXT_3 }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right">
|
||||
<button onClick={() => setDetailRow(r)} className="rounded-full font-extrabold cursor-pointer" 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="px-4 py-2.5 border-t text-[10px] font-bold uppercase tracking-wider" style={{ borderColor: BORDER, background: SURFACE_ALT, color: TEXT_2 }}>
|
||||
{rows.length} {status} · {BATCHES.find((b) => b.id === batch)?.label} wave
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailRow && <DeliveryDetailModal row={detailRow} onClose={() => setDetailRow(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Delivery details modal ──────────────────────────────────────────────────────
|
||||
function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }) {
|
||||
const orderheaderid = row.orderheaderid ?? row.orderid;
|
||||
const detailsQ = useFiestaOrderDetails(orderheaderid as number | string);
|
||||
const lines = (detailsQ.data ?? []).map((d) => {
|
||||
const quantity = fnum(d.quantity) || fnum(d.qty) || fnum(d.orderqty);
|
||||
const price = fnum(d.price) || fnum(d.unitprice) || fnum(d.retailprice);
|
||||
return { name: fstr(d.productname) || fstr(d.itemname) || 'Item', quantity, price, lineTotal: fnum(d.amount) || fnum(d.productsumprice) || price * quantity };
|
||||
});
|
||||
const st = fstr(row.orderstatus).toLowerCase();
|
||||
const rider = fstr(row.ridername) || fstr(row.username);
|
||||
const steps = [
|
||||
{ label: 'Assigned', field: 'assigntime' }, { label: 'Accepted', field: 'acceptedtime' }, { label: 'Arrived', field: 'arrivaltime' },
|
||||
{ label: 'Picked', field: 'pickuptime' }, { label: 'Delivered', field: 'deliverytime' },
|
||||
];
|
||||
|
||||
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, #6366f1 0%, ${soft('#6366f1')} 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 }}><Truck size={16} style={{ color: '#6366f1' }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}</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(DELIVERY_STATUS, st)} />
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_2 }}><UserCheck size={12} /> {rider || 'Unassigned'}</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl space-y-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
||||
<div className="font-bold" style={{ color: TEXT }}>{fstr(row.deliverycustomer) || 'Customer'}</div>
|
||||
{fstr(row.deliverycontactno) && <div className="flex items-center gap-2 font-mono text-xs" style={{ color: TEXT_2 }}><Phone size={12} /> {fstr(row.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(row.deliveryaddress) || fstr(row.deliverysuburb) || 'Address unavailable'}</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2" style={{ color: TEXT_2 }}>Delivery Timeline</span>
|
||||
<div className="space-y-2.5 pl-1">
|
||||
{steps.map((s) => {
|
||||
const ts = fstr(row[s.field]); const done = Boolean(ts);
|
||||
return (
|
||||
<div key={s.field} className="flex items-center gap-2.5">
|
||||
<CheckCircle2 size={13} style={{ color: done ? '#10b981' : '#cbd5e1' }} />
|
||||
<span className="font-semibold text-xs" style={{ color: done ? TEXT : TEXT_3 }}>{s.label}</span>
|
||||
<span className="ml-auto text-[10px] font-mono" style={{ color: TEXT_3 }}>{done ? shortTime(ts) : '—'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-extrabold uppercase tracking-wide mb-2 flex items-center gap-1.5" style={{ color: TEXT_2 }}><Package size={12} /> 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 items…</div>}
|
||||
{!detailsQ.isLoading && lines.length === 0 && <div className="py-2 text-[11px] font-medium" style={{ color: TEXT_3 }}>No line items returned.</div>}
|
||||
{lines.map((item, idx) => (
|
||||
<div key={idx} className="py-2 flex justify-between items-center" style={{ borderTop: idx ? `1px solid ${DIVIDER}` : 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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AwaitingApi label="Reassign · Cancel · Notify rider" api="dispatch backend" compact />
|
||||
</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})` }}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
305
src/components/DeliveryReportsView.tsx
Normal file
305
src/components/DeliveryReportsView.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delivery Reports — replicated from the operations console (nearle_console/
|
||||
* reports), rebuilt against the shared console UI kit (`./consoleUi`) so it
|
||||
* matches the source design (gradient header, KPI cards, pill tabs, status-chip /
|
||||
* metric-pill tables, gradient total bars, gradient export button). Three report
|
||||
* tabs map onto the live Fiesta endpoints: Orders Summary (getlocationsummary),
|
||||
* Riders Summary (getfleetsummary), Orders Details (getdeliveries + CSV). The
|
||||
* map-based reports need a mapping stack / GPS telemetry → "awaiting backend".
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { TrendingUp, Clock, CheckCircle2, IndianRupee, Bike, Truck, Calendar, Download, Store, ClipboardList, Route } from 'lucide-react';
|
||||
import { useFiestaLocationSummary, useFiestaFleetSummary, useFiestaDeliveries } 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 AwaitingApi from './AwaitingApi';
|
||||
import {
|
||||
GradientHeader, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
|
||||
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, ring,
|
||||
} from './consoleUi';
|
||||
|
||||
type ReportTab = 'orders-summary' | 'riders-summary' | 'orders-details' | 'maps';
|
||||
const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> = [
|
||||
{ key: 'orders-summary', label: 'Orders Summary', icon: Store },
|
||||
{ key: 'riders-summary', label: 'Riders Summary', icon: Bike },
|
||||
{ key: 'orders-details', label: 'Orders Details', icon: ClipboardList },
|
||||
{ key: 'maps', label: 'Rider Routes', icon: Route },
|
||||
];
|
||||
|
||||
interface DeliveryReportsViewProps { searchQuery?: string; }
|
||||
|
||||
export default function DeliveryReportsView({ searchQuery = '' }: DeliveryReportsViewProps) {
|
||||
const today = new Date();
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const [fromdate, setFromdate] = useState<string>(ymd(monthStart));
|
||||
const [todate, setTodate] = useState<string>(ymd(today));
|
||||
const [tab, setTab] = useState<ReportTab>('orders-summary');
|
||||
|
||||
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: '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';
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<GradientHeader title="Delivery Reports" subtitle="Operational analytics across outlets, riders, and the full order lifecycle." />
|
||||
|
||||
{/* Tab nav */}
|
||||
<FilterBar className="mb-4">
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
{TABS.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<React.Fragment key={t.key}>
|
||||
<Pill active={tab === t.key} color={BRAND} onClick={() => setTab(t.key)}><Icon size={14} /> {t.label}</Pill>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{/* Shared date range */}
|
||||
<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 }} /> Period</span>
|
||||
{presets.map((p) => (
|
||||
<React.Fragment key={p.key}><Pill active={activePreset === p.key} color={BRAND} onClick={() => { setFromdate(p.from); setTodate(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) => setFromdate(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) => setTodate(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>
|
||||
|
||||
{tab === 'orders-summary' && <OrdersSummaryReport />}
|
||||
{tab === 'riders-summary' && <RidersSummaryReport fromdate={fromdate} todate={todate} />}
|
||||
{tab === 'orders-details' && <OrdersDetailsReport fromdate={fromdate} todate={todate} searchQuery={searchQuery} />}
|
||||
{tab === 'maps' && (
|
||||
<div className="bg-white border rounded-2xl p-4" style={{ borderColor: BORDER }}>
|
||||
<span className="text-[10px] font-extrabold uppercase tracking-widest flex items-center gap-1.5 mb-2" style={{ color: TEXT_2 }}><Route size={12} /> Planned routes & live rider logs</span>
|
||||
<AwaitingApi label="Rider route maps & live location logs" api="maps + rider telemetry" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Cnt = ({ n, color }: { n: number; color: string }) => (n > 0 ? <MetricPill color={color} minWidth={34}>{n.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3, fontWeight: 700 }}>0</span>);
|
||||
|
||||
function TableShell({ minWidth, head, children, footer }: { minWidth: number; head: string[]; children: React.ReactNode; footer?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth }}>
|
||||
<thead><tr>{head.map((h, i) => (<th key={i} className={`px-3 py-2.5 ${i < 2 ? 'text-left' : 'text-right'}`} style={TH_STYLE}>{h}</th>))}</tr></thead>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Orders Summary (per outlet) ──────────────────────────────────────────────────
|
||||
function OrdersSummaryReport() {
|
||||
const q = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
||||
const rows = q.data ?? [];
|
||||
const totals = rows.reduce((a, r) => ({ total: a.total + r.total, pending: a.pending + r.pending, delivered: a.delivered + r.delivered, cancelled: a.cancelled + r.cancelled }), { total: 0, pending: 0, delivered: 0, cancelled: 0 });
|
||||
const kpis = [
|
||||
{ label: 'Total Orders', value: totals.total.toLocaleString('en-IN'), color: BRAND, icon: <TrendingUp size={20} /> },
|
||||
{ label: 'Pending', value: totals.pending.toLocaleString('en-IN'), color: '#f59e0b', icon: <Clock size={20} /> },
|
||||
{ label: 'Delivered', value: totals.delivered.toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} /> },
|
||||
{ label: 'Outlets', value: rows.length.toLocaleString('en-IN'), color: '#0ea5e9', icon: <Store size={20} /> },
|
||||
];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<KpiStrip items={kpis} loading={q.isLoading} />
|
||||
<TableShell minWidth={820} head={['#', 'Outlet', 'All', 'Created', 'Pending', 'Processing', 'Delivered', 'Cancelled']}
|
||||
footer={rows.length > 0 ? <TotalBar chips={[{ label: `${totals.total} orders`, color: BRAND }, { label: `${totals.delivered} delivered`, color: '#10b981' }, { label: `${totals.pending} pending`, color: '#f59e0b' }]} /> : undefined}>
|
||||
{q.isLoading ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>Loading outlet summary…</td></tr>
|
||||
: rows.length === 0 ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No outlet data available.</td></tr>
|
||||
: rows.map((r, i) => (
|
||||
<tr key={r.locationid || i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER}` }} 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 }}>{i + 1}</td>
|
||||
<td className="px-3 py-2.5 font-extrabold text-[13px]" style={{ color: TEXT }}>{r.locationname || `Location ${r.locationid}`}</td>
|
||||
<td className="px-3 py-2.5 text-right font-extrabold font-mono" style={{ color: TEXT }}>{r.total.toLocaleString('en-IN')}</td>
|
||||
<td className="px-3 py-2.5 text-right"><Cnt n={r.created} color="#0ea5e9" /></td>
|
||||
<td className="px-3 py-2.5 text-right"><Cnt n={r.pending} color="#f59e0b" /></td>
|
||||
<td className="px-3 py-2.5 text-right"><Cnt n={r.processing} color="#6366f1" /></td>
|
||||
<td className="px-3 py-2.5 text-right"><Cnt n={r.delivered} color="#10b981" /></td>
|
||||
<td className="px-3 py-2.5 text-right"><Cnt n={r.cancelled} color="#ef4444" /></td>
|
||||
</tr>
|
||||
))}
|
||||
</TableShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Riders Summary (per rider) ───────────────────────────────────────────────────
|
||||
function RidersSummaryReport({ fromdate, todate }: { fromdate: string; todate: string }) {
|
||||
const q = useFiestaFleetSummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
||||
const rows = q.data ?? [];
|
||||
const mapped = rows.map((r) => ({
|
||||
name: fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() || fstr(r.username) || `Rider ${fstr(r.userid)}`,
|
||||
orders: fnum(r.totalorders) || fnum(r.orders), delivered: fnum(r.delivered) || fnum(r.deliveriescompleted) || fnum(r.completed),
|
||||
pending: fnum(r.pending) || fnum(r.deliveriespending), cancelled: fnum(r.cancelled) || fnum(r.deliveriescancelled),
|
||||
kms: fnum(r.kms), actualKms: fnum(r.cumulativekms), amount: fnum(r.deliveryamt) || fnum(r.charges) || fnum(r.deliverycharges),
|
||||
}));
|
||||
const totals = mapped.reduce((a, r) => ({ orders: a.orders + r.orders, delivered: a.delivered + r.delivered, amount: a.amount + r.amount }), { orders: 0, delivered: 0, amount: 0 });
|
||||
const kpis = [
|
||||
{ label: 'Active Riders', value: mapped.length.toLocaleString('en-IN'), color: BRAND, icon: <Bike size={20} /> },
|
||||
{ label: 'Total Orders', value: totals.orders.toLocaleString('en-IN'), color: '#0ea5e9', icon: <Truck size={20} /> },
|
||||
{ label: 'Delivered', value: totals.delivered.toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} /> },
|
||||
{ label: 'Total Amount', value: `₹${totals.amount.toLocaleString('en-IN')}`, color: '#f59e0b', icon: <IndianRupee size={20} /> },
|
||||
];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<KpiStrip items={kpis} loading={q.isLoading} />
|
||||
<TableShell minWidth={820} head={['#', 'Rider', 'Orders', 'Pending', 'Cancelled', 'Delivered', 'KMs', 'Amount']}
|
||||
footer={mapped.length > 0 ? <TotalBar chips={[{ label: `${totals.orders} orders`, color: BRAND }, { label: `${totals.delivered} delivered`, color: '#10b981' }]} grand={`₹${totals.amount.toLocaleString('en-IN')}`} /> : undefined}>
|
||||
{q.isLoading ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>Loading rider summary…</td></tr>
|
||||
: q.isError ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: '#ef4444' }}>Rider summary unavailable for this period.</td></tr>
|
||||
: mapped.length === 0 ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No rider activity in this period.</td></tr>
|
||||
: mapped.map((r, i) => (
|
||||
<tr key={i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER}` }} 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 }}>{i + 1}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="rounded-full flex items-center justify-center shrink-0" style={{ width: 26, height: 26, background: soft('#8b5cf6'), color: '#8b5cf6' }}><Bike size={13} /></span>
|
||||
<span className="font-extrabold text-[13px]" style={{ color: TEXT }}>{r.name}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right font-extrabold font-mono" style={{ color: TEXT }}>{r.orders.toLocaleString('en-IN')}</td>
|
||||
<td className="px-3 py-2.5 text-right"><Cnt n={r.pending} color="#f59e0b" /></td>
|
||||
<td className="px-3 py-2.5 text-right"><Cnt n={r.cancelled} color="#ef4444" /></td>
|
||||
<td className="px-3 py-2.5 text-right"><Cnt n={r.delivered} color="#10b981" /></td>
|
||||
<td className="px-3 py-2.5 text-right font-mono text-xs" style={{ color: TEXT_2 }}>{r.kms ? r.kms.toFixed(1) : '—'}{r.actualKms > 0 ? ` / ${r.actualKms.toFixed(1)}` : ''}</td>
|
||||
<td className="px-3 py-2.5 text-right">{r.amount > 0 ? <MetricPill color="#10b981" minWidth={72}>₹{r.amount.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Orders Details (line-level + CSV) ────────────────────────────────────────────
|
||||
const DETAIL_STATUSES = ['all', 'pending', 'accepted', 'arrived', 'picked', 'active', 'delivered', 'skipped', 'cancelled'] as const;
|
||||
type DetailStatus = (typeof DETAIL_STATUSES)[number];
|
||||
|
||||
function OrdersDetailsReport({ fromdate, todate, searchQuery }: { fromdate: string; todate: string; searchQuery: string }) {
|
||||
const q = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
||||
const allRows = q.data ?? [];
|
||||
const [status, setStatus] = useState<DetailStatus>('all');
|
||||
const [localSearch, setLocalSearch] = useState('');
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
const acc: Record<string, number> = {};
|
||||
for (const r of allRows) { const s = fstr(r.orderstatus).toLowerCase(); acc[s] = (acc[s] ?? 0) + 1; }
|
||||
return acc;
|
||||
}, [allRows]);
|
||||
const rows = useMemo(() => {
|
||||
const term = (localSearch || searchQuery).toLowerCase();
|
||||
return allRows.filter((r) => {
|
||||
if (status !== 'all' && fstr(r.orderstatus).toLowerCase() !== status) return false;
|
||||
if (!term) return true;
|
||||
return [r.orderid, r.deliverycustomer, r.deliveryaddress, r.ridername].some((f) => fstr(f).toLowerCase().includes(term));
|
||||
});
|
||||
}, [allRows, status, localSearch, searchQuery]);
|
||||
|
||||
const exportCsv = () => {
|
||||
const headers = ['Order ID', 'Status', 'Rider', 'Customer', 'Suburb', 'Address', 'Assigned', 'Delivered', 'KMs', 'Actual KMs', 'Charges', 'Amount'];
|
||||
const esc = (v: unknown) => `"${fstr(v).replace(/"/g, '""')}"`;
|
||||
const lines = rows.map((r) => [r.orderid, r.orderstatus, fstr(r.ridername) || fstr(r.username), r.deliverycustomer, r.deliverysuburb, r.deliveryaddress, shortTime(r.assigntime), shortTime(r.deliverytime), fnum(r.kms), fnum(r.cumulativekms), fnum(r.deliverycharges), 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_Detail_${fromdate}_to_${todate}.csv`; a.click(); URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FilterBar>
|
||||
<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">
|
||||
{DETAIL_STATUSES.map((s) => {
|
||||
const color = s === 'all' ? BRAND : statusColor(DELIVERY_STATUS, s);
|
||||
return (
|
||||
<React.Fragment key={s}>
|
||||
<Pill active={status === s} color={color} onClick={() => setStatus(s)} count={s === 'all' ? allRows.length : statusCounts[s] ?? 0}>
|
||||
<span className="capitalize">{s}</span>
|
||||
</Pill>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 lg:shrink-0">
|
||||
<div className="w-full lg:w-56"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search…" /></div>
|
||||
<button onClick={exportCsv} disabled={rows.length === 0} 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"
|
||||
style={{ padding: '7px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}>
|
||||
<Download size={13} /> CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
<TableShell minWidth={1040} head={['#', 'Order', 'Drop', 'Rider', 'Assigned', 'Delivered', 'KMs', 'Charges', 'Status']}
|
||||
footer={<div className="px-4 py-2.5 border-t text-[10px] font-bold uppercase tracking-wider" style={{ borderColor: BORDER, background: SURFACE_ALT, color: TEXT_2 }}>{rows.length} rows · {fromdate} → {todate}</div>}>
|
||||
{q.isLoading ? <tr><td colSpan={9} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>Loading order details…</td></tr>
|
||||
: rows.length === 0 ? <tr><td colSpan={9} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No deliveries match this filter.</td></tr>
|
||||
: rows.map((r, i) => {
|
||||
const st = fstr(r.orderstatus).toLowerCase();
|
||||
const rider = fstr(r.ridername) || fstr(r.username);
|
||||
const charge = fnum(r.deliverycharges) || fnum(r.deliveryamt);
|
||||
return (
|
||||
<tr key={fstr(r.deliveryid) || fstr(r.orderid) || i} className="transition-colors align-top" style={{ borderBottom: `1px solid ${DIVIDER}` }} onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
|
||||
<td className="px-3 py-2.5 font-mono text-left" style={{ color: TEXT_3 }}>{i + 1}</td>
|
||||
<td className="px-3 py-2.5 text-left">
|
||||
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}</p>
|
||||
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.deliverydate || r.assigntime)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-left">
|
||||
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
|
||||
<p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right font-medium text-xs truncate max-w-[110px]" style={{ color: TEXT_2 }}>{rider || '—'}</td>
|
||||
<td className="px-3 py-2.5 text-right font-mono text-xs" style={{ color: TEXT_2 }}>{shortTime(r.assigntime) || '—'}</td>
|
||||
<td className="px-3 py-2.5 text-right font-mono text-xs" style={{ color: TEXT_2 }}>{fstr(r.deliverytime) ? shortTime(r.deliverytime) : '—'}</td>
|
||||
<td className="px-3 py-2.5 text-right">{fnum(r.kms) ? <MetricPill color="#ef4444" minWidth={52}>{fnum(r.kms).toFixed(1)}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
||||
<td className="px-3 py-2.5 text-right">{charge > 0 ? <MetricPill color="#10b981" minWidth={64}>₹{charge.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
||||
<td className="px-3 py-2.5 text-right"><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Total bar (gradient) ─────────────────────────────────────────────────────────
|
||||
function TotalBar({ chips, grand }: { chips: Array<{ label: string; color: string }>; grand?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between flex-wrap gap-2 px-4 py-3 border-t" style={{ borderColor: BORDER, background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)` }}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{chips.map((c, i) => (
|
||||
<span key={i} className="inline-flex items-center rounded-full font-bold" style={{ padding: '3px 10px', fontSize: 11.5, background: soft(c.color), color: c.color, border: `1px solid ${edge(c.color)}` }}>{c.label}</span>
|
||||
))}
|
||||
</div>
|
||||
{grand && (
|
||||
<span className="inline-flex items-center rounded-full font-extrabold text-white" style={{ padding: '4px 12px', fontSize: 13, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 16px ${ring(BRAND)}` }}>{grand}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
313
src/components/DispatchMap.tsx
Normal file
313
src/components/DispatchMap.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Leaflet map for the Dispatch cockpit — the live route map that replaces the
|
||||
* earlier gated placeholder. Plots each delivery's drop point as a numbered pin
|
||||
* (coloured per rider) and, when a rider/zone is focused, draws the planned stop
|
||||
* sequence as a dashed polyline. Uses OpenStreetMap tiles via react-leaflet.
|
||||
*
|
||||
* Coordinates come from the delivery rows (droplat/droplon, falling back to
|
||||
* deliverylat/deliverylong). Rows without coordinates are simply not plotted.
|
||||
* (Road-snapped routing + live rider GPS remain backend work — this draws the
|
||||
* planned order with straight segments, not fabricated GPS traces.)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Polyline, useMap } from 'react-leaflet';
|
||||
import { Bike, Mailbox, Utensils, MapPin, Map as MapIcon, Ruler, X } from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
export interface MapPoint {
|
||||
id: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
step: number;
|
||||
color: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
status: string;
|
||||
/** Full delivery row, for the rich click popup. */
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Popup helpers (replicated from nearle_console renderOrderPopupContent) ────────
|
||||
const STATUS_HEX: Record<string, string> = {
|
||||
created: '#0ea5e9', processing: '#0ea5e9', pending: '#f59e0b', accepted: '#6366f1',
|
||||
arrived: '#06b6d4', picked: '#8b5cf6', active: '#14b8a6', skipped: '#f97316',
|
||||
delivered: '#22c55e', cancelled: '#ef4444',
|
||||
};
|
||||
function statusStyle(s: string) {
|
||||
const k = String(s || '').toLowerCase();
|
||||
const hex = STATUS_HEX[k] || '#64748b';
|
||||
return { bg: `${hex}1f`, fg: hex, label: k ? k.charAt(0).toUpperCase() + k.slice(1) : '—' };
|
||||
}
|
||||
function fmtTime(raw: unknown): string {
|
||||
const m = String(raw ?? '').match(/(\d{1,2}):(\d{2})/);
|
||||
return m ? `${m[1]}:${m[2]}` : '';
|
||||
}
|
||||
const POPUP_TIMELINE: Array<{ key: string; label: string; final?: boolean }> = [
|
||||
{ key: 'assigntime', label: 'Assigned' },
|
||||
{ key: 'acceptedtime', label: 'Accepted' },
|
||||
{ key: 'arrivaltime', label: 'Arrived' },
|
||||
{ key: 'pickuptime', label: 'Pickup' },
|
||||
{ key: 'starttime', label: 'Started' },
|
||||
{ key: 'deliverytime', label: 'Delivered', final: true },
|
||||
];
|
||||
const S = (v: unknown) => (v == null ? '' : String(v));
|
||||
|
||||
function PuDetail({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="pu-detail">
|
||||
<div className="pu-detail-icon">{icon}</div>
|
||||
<div className="pu-detail-body">
|
||||
<div className="pu-detail-label">{label}</div>
|
||||
<div className="pu-detail-value" title={value}>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The centered click popup — same structure/classes as the source console. */
|
||||
function OrderPopup({ o, onClose }: { o: Record<string, unknown>; onClose: () => void }) {
|
||||
const st = S(o.orderstatus).toLowerCase();
|
||||
const ss = statusStyle(st);
|
||||
const rider = S(o.rider_name) || S(o.ridername) || S(o.username) || 'Unassigned';
|
||||
const customer = S(o.deliverycustomer) || S(o.customername);
|
||||
const pickup = S(o.pickupcustomer) || S(o.locationname) || S(o.pickuplocation);
|
||||
const drop = S(o.deliverysuburb) || S(o.deliveryaddress);
|
||||
const zone = S(o.zone_name);
|
||||
const riderId = o.rider_id || o.userid;
|
||||
const kms = o.kms;
|
||||
const actual = o.actualkms ?? o.cumulativekms;
|
||||
const hasTimeline = POPUP_TIMELINE.some((t) => fmtTime(o[t.key]));
|
||||
const hasDistance = (kms != null && kms !== '') || (actual != null && Number(actual) > 0);
|
||||
|
||||
return (
|
||||
<div className="dispatch-popup-center" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="dispatch-popup-card dispatch-popup">
|
||||
<button className="dispatch-popup-center-close" onClick={onClose} aria-label="Close">
|
||||
<X size={15} />
|
||||
</button>
|
||||
<div style={{ height: '100%', width: '100%' }}>
|
||||
<div className="pu-header">
|
||||
<div className="pu-header-top">
|
||||
<div className="pu-id">ORDER #{S(o.orderid) || S(o.deliveryid)}</div>
|
||||
{st && <span className="pu-status-chip" style={{ background: ss.bg, color: ss.fg }}>{ss.label}</span>}
|
||||
</div>
|
||||
<div className="pu-rider"><Bike size={13} /> <span>{rider}</span></div>
|
||||
{customer && <div className="pu-customer" title={customer}><Mailbox size={13} /><span>{customer}</span></div>}
|
||||
{o.deliveryid != null && <div className="pu-delivery-id">Delivery #{S(o.deliveryid)}</div>}
|
||||
</div>
|
||||
|
||||
<div className="pu-body">
|
||||
{hasTimeline && (
|
||||
<div className="pu-section">
|
||||
<div className="pu-section-label">Timeline</div>
|
||||
<div className="pu-timeline">
|
||||
{POPUP_TIMELINE.map((t) => {
|
||||
const time = fmtTime(o[t.key]);
|
||||
if (!time) return null;
|
||||
return (
|
||||
<div key={t.key} className={`pu-tl-row ${t.final ? 'delivered' : ''}`}>
|
||||
<span className="pu-tl-dot" />
|
||||
<span className="pu-tl-label">{t.label}</span>
|
||||
<span className="pu-tl-time">{time}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pu-section">
|
||||
<div className="pu-section-label">Details</div>
|
||||
<div className="pu-details-grid">
|
||||
{pickup && <PuDetail icon={<Utensils size={13} />} label="Pickup" value={pickup} />}
|
||||
{drop && <PuDetail icon={<MapPin size={13} />} label="Drop" value={drop} />}
|
||||
{zone && <PuDetail icon={<MapIcon size={13} />} label="Zone" value={zone} />}
|
||||
{riderId ? <PuDetail icon={<Bike size={13} />} label="Rider ID" value={`#${S(riderId)}`} /> : null}
|
||||
</div>
|
||||
|
||||
{hasDistance && (
|
||||
<div className="pu-distance-row">
|
||||
{kms != null && kms !== '' && (
|
||||
<div className="pu-distance-chip">
|
||||
<span className="pu-distance-icon"><Ruler size={12} /></span>
|
||||
<span className="pu-distance-label">Planned</span>
|
||||
<span className="pu-distance-value">{S(kms)} km</span>
|
||||
</div>
|
||||
)}
|
||||
{actual != null && Number(actual) > 0 && (
|
||||
<div className="pu-distance-chip">
|
||||
<span className="pu-distance-icon"><Ruler size={12} /></span>
|
||||
<span className="pu-distance-label">Actual</span>
|
||||
<span className="pu-distance-value">{Number(actual).toFixed(2)} km</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const COIMBATORE: [number, number] = [11.0168, 76.9558];
|
||||
|
||||
/**
|
||||
* Road-following route through the ordered waypoints via the public OSRM service.
|
||||
* Returns the snapped geometry as [lat, lon][] (Leaflet order), or null on failure
|
||||
* (caller then falls back to straight segments).
|
||||
*/
|
||||
async function fetchRoadRoute(waypoints: Array<[number, number]>, signal: AbortSignal): Promise<Array<[number, number]> | null> {
|
||||
if (waypoints.length < 2) return null;
|
||||
const path = waypoints.map(([la, lo]) => `${lo},${la}`).join(';'); // OSRM wants lon,lat
|
||||
const url = `https://router.project-osrm.org/route/v1/driving/${path}?overview=full&geometries=geojson`;
|
||||
try {
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const coords = data?.routes?.[0]?.geometry?.coordinates;
|
||||
if (!Array.isArray(coords)) return null;
|
||||
return coords.map((c: [number, number]) => [c[1], c[0]] as [number, number]); // → lat,lon
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Square hub/pickup marker. */
|
||||
function hubIcon(): L.DivIcon {
|
||||
return L.divIcon({
|
||||
className: 'dispatch-hub',
|
||||
html: `<div style="width:26px;height:26px;border-radius:7px;background:#0f172a;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:11px;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.35)">H</div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 13],
|
||||
popupAnchor: [0, -13],
|
||||
});
|
||||
}
|
||||
|
||||
/** Numbered circular pin coloured to the point's rider/route. */
|
||||
function pinIcon(step: number, color: string, dim: boolean): L.DivIcon {
|
||||
return L.divIcon({
|
||||
className: 'dispatch-pin',
|
||||
html: `<div style="width:28px;height:28px;border-radius:50%;background:${color};opacity:${dim ? 0.6 : 1};color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:11px;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.35)">${step}</div>`,
|
||||
iconSize: [28, 28],
|
||||
iconAnchor: [14, 14],
|
||||
popupAnchor: [0, -14],
|
||||
});
|
||||
}
|
||||
|
||||
/** Fit the view to the plotted points; invalidate size when the layout changes. */
|
||||
function MapController({ points, resizeKey }: { points: MapPoint[]; resizeKey: unknown }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (points.length === 0) return;
|
||||
if (points.length === 1) {
|
||||
map.setView([points[0].lat, points[0].lon], 14);
|
||||
return;
|
||||
}
|
||||
const bounds = L.latLngBounds(points.map((p) => [p.lat, p.lon] as [number, number]));
|
||||
map.fitBounds(bounds, { padding: [48, 48], maxZoom: 15 });
|
||||
}, [points, map]);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => map.invalidateSize(), 220);
|
||||
return () => clearTimeout(t);
|
||||
}, [resizeKey, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function DispatchMap({
|
||||
points,
|
||||
route,
|
||||
routeColor = '#581c87',
|
||||
start,
|
||||
resizeKey,
|
||||
animateNonce = 0,
|
||||
}: {
|
||||
points: MapPoint[];
|
||||
route?: boolean;
|
||||
routeColor?: string;
|
||||
/** Optional pickup/hub the route starts from. */
|
||||
start?: [number, number] | null;
|
||||
resizeKey?: unknown;
|
||||
/** Increment to (re)play the route-draw animation. */
|
||||
animateNonce?: number;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Record<string, unknown> | null>(null);
|
||||
// Straight segments through the ordered stops (hub → drops), used as the
|
||||
// fallback and shown instantly while the road route is fetched.
|
||||
const straight: Array<[number, number]> = [
|
||||
...(start ? [start] : []),
|
||||
...points.map((p) => [p.lat, p.lon] as [number, number]),
|
||||
];
|
||||
|
||||
// Road-snapped geometry from OSRM (null until it resolves / on failure).
|
||||
const [roadLine, setRoadLine] = useState<Array<[number, number]> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!route || straight.length < 2) { setRoadLine(null); return; }
|
||||
const ctrl = new AbortController();
|
||||
fetchRoadRoute(straight, ctrl.signal).then((line) => setRoadLine(line));
|
||||
return () => ctrl.abort();
|
||||
// Re-fetch when the ordered coordinate set changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [route, JSON.stringify(straight)]);
|
||||
|
||||
const line = roadLine ?? straight;
|
||||
|
||||
// Route-draw animation: reveal the line progressively when animateNonce changes.
|
||||
const [drawn, setDrawn] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!animateNonce || !route || line.length < 2) return;
|
||||
let raf = 0;
|
||||
const t0 = performance.now();
|
||||
const dur = 2200;
|
||||
const total = line.length;
|
||||
const tick = (t: number) => {
|
||||
const p = Math.min(1, (t - t0) / dur);
|
||||
setDrawn(Math.max(2, Math.floor(p * total)));
|
||||
if (p < 1) raf = requestAnimationFrame(tick);
|
||||
else setDrawn(null);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => { cancelAnimationFrame(raf); setDrawn(null); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [animateNonce]);
|
||||
const shownLine = drawn == null ? line : line.slice(0, drawn);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', height: '100%', width: '100%' }}>
|
||||
<MapContainer center={COIMBATORE} zoom={12} scrollWheelZoom style={{ height: '100%', width: '100%' }} zoomControl>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{route && shownLine.length > 1 && (
|
||||
<Polyline
|
||||
positions={shownLine}
|
||||
pathOptions={{ color: routeColor, weight: 4, opacity: 0.85, dashArray: roadLine ? undefined : '6 8' }}
|
||||
/>
|
||||
)}
|
||||
{route && start && (
|
||||
<Marker position={start} icon={hubIcon()} eventHandlers={{ click: () => setSelected({ orderid: 'PICKUP', deliverycustomer: 'Pickup hub', pickupcustomer: 'Ragul Stores Hub' }) }} />
|
||||
)}
|
||||
{points.map((p) => (
|
||||
<Marker
|
||||
key={p.id}
|
||||
position={[p.lat, p.lon]}
|
||||
icon={pinIcon(p.step, p.color, p.status.toLowerCase() === 'cancelled')}
|
||||
eventHandlers={{ click: () => setSelected(p.raw) }}
|
||||
/>
|
||||
))}
|
||||
<MapController points={points} resizeKey={resizeKey} />
|
||||
</MapContainer>
|
||||
|
||||
{selected && <OrderPopup o={selected} onClose={() => setSelected(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10923
src/components/DispatchView.css
Normal file
10923
src/components/DispatchView.css
Normal file
File diff suppressed because it is too large
Load Diff
727
src/components/DispatchView.tsx
Normal file
727
src/components/DispatchView.tsx
Normal file
@@ -0,0 +1,727 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dispatch page — a faithful port of the operations console's dispatch cockpit
|
||||
* (nearle_console/dispatch). It reuses that page's actual stylesheet verbatim
|
||||
* (`./DispatchView.css`, copied from Dispatch.css) and reproduces the same DOM /
|
||||
* class structure: the `#hdr` bar, `#strat-row` view tabs, `#batch-row` wave
|
||||
* selector, the 400px `#sidebar` (RIDER DISPATCH header + KPI tiles + rider/zone
|
||||
* cards + per-trip order cards), and the `#map-wrap` centrepiece.
|
||||
*
|
||||
* The source map is a Leaflet canvas of planned-vs-actual rider routes (OSRM
|
||||
* road-snapping, Kalman-smoothed GPS) plus AI rider-assignment posting to
|
||||
* external optimisation services. Those need a mapping stack + dispatch backends
|
||||
* this tenant doesn't expose, so the `#map-wrap` plots the real planned stop
|
||||
* order and marks the live-GPS / compare / AI-assign layers as awaiting backend —
|
||||
* no fabricated telemetry. Everything else is driven by the live Fiesta feed.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Bike,
|
||||
Globe,
|
||||
Info,
|
||||
Package,
|
||||
Ruler,
|
||||
Wallet,
|
||||
Crosshair,
|
||||
Clock,
|
||||
Utensils,
|
||||
Mailbox,
|
||||
StickyNote,
|
||||
ArrowLeftRight,
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
List,
|
||||
Play,
|
||||
PlugZap,
|
||||
} from 'lucide-react';
|
||||
import { useFiestaDeliveries, useFiestaRiders } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||
import { MOCK_DELIVERIES, MOCK_RIDERS } from '../services/dispatchMockData';
|
||||
import DispatchMap, { type MapPoint } from './DispatchMap';
|
||||
import './DispatchView.css';
|
||||
|
||||
// ── Status colours (match the console palette) ───────────────────────────────────
|
||||
const STATUS_HEX: Record<string, string> = {
|
||||
pending: '#f59e0b',
|
||||
accepted: '#6366f1',
|
||||
arrived: '#06b6d4',
|
||||
picked: '#8b5cf6',
|
||||
active: '#14b8a6',
|
||||
skipped: '#f97316',
|
||||
delivered: '#22c55e',
|
||||
cancelled: '#ef4444',
|
||||
};
|
||||
function statusStyle(s: string): React.CSSProperties {
|
||||
const hex = STATUS_HEX[s.toLowerCase()] || '#64748b';
|
||||
return { background: `${hex}1f`, color: hex };
|
||||
}
|
||||
|
||||
// Stable rider/zone colour.
|
||||
const COLORS = ['#3b82f6', '#a855f7', '#10b981', '#f59e0b', '#ef4444', '#6366f1', '#14b8a6', '#ec4899', '#f97316', '#06b6d4'];
|
||||
function colorFor(key: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < key.length; i++) hash = key.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return COLORS[Math.abs(hash) % COLORS.length];
|
||||
}
|
||||
|
||||
/** Drop coordinates from a delivery row (several field spellings), or null. */
|
||||
function dropLatLon(r: Row): [number, number] | null {
|
||||
const lat = fnum(r.droplat) || fnum(r.deliverylat) || fnum(r.deliverylatitude);
|
||||
const lon = fnum(r.droplon) || fnum(r.deliverylong) || fnum(r.deliverylon) || fnum(r.deliverylongitude);
|
||||
return lat && lon ? [lat, lon] : null;
|
||||
}
|
||||
|
||||
/** Pickup/hub coordinates from a delivery row, or null. */
|
||||
function pickupLatLon(r: Row): [number, number] | null {
|
||||
const lat = fnum(r.pickuplat) || fnum(r.pickuplatitude);
|
||||
const lon = fnum(r.pickuplong) || fnum(r.picklongitude) || fnum(r.pickuplon);
|
||||
return lat && lon ? [lat, lon] : null;
|
||||
}
|
||||
|
||||
// ── Batch / wave model (canonical half-open hour ranges, local time) ─────────────
|
||||
// Mirrors Dispatch.js BATCH_OPTIONS: gaps (8–9, 12:30–16, after 19) are intentional.
|
||||
type BatchId = 'all' | 'morning' | 'afternoon' | 'evening';
|
||||
const BATCHES: Array<{ id: BatchId; label: string; range: string }> = [
|
||||
{ id: 'all', label: 'All', range: 'Full day' },
|
||||
{ id: 'morning', label: 'Morning', range: '12 AM – 8 AM' },
|
||||
{ id: 'afternoon', label: 'Afternoon', range: '9 AM – 12:30 PM' },
|
||||
{ id: 'evening', label: 'Evening', range: '4 PM – 7 PM' },
|
||||
];
|
||||
function rowHourFrac(r: Row): number | null {
|
||||
const raw = fstr(r.assigntime) || fstr(r.deliverytime) || fstr(r.deliverydate);
|
||||
const m = raw.match(/[ T](\d{1,2}):(\d{2})/);
|
||||
if (!m) return null;
|
||||
return Number(m[1]) + Number(m[2]) / 60;
|
||||
}
|
||||
function inBatch(r: Row, b: BatchId): boolean {
|
||||
if (b === 'all') return true;
|
||||
const h = rowHourFrac(r);
|
||||
if (h == null) return false;
|
||||
if (b === 'morning') return h >= 0 && h < 8;
|
||||
if (b === 'afternoon') return h >= 9 && h < 12.5;
|
||||
return h >= 16 && h < 19; // evening
|
||||
}
|
||||
function initialBatch(): BatchId {
|
||||
const h = new Date().getHours();
|
||||
if (h >= 0 && h < 8) return 'morning';
|
||||
if (h >= 9 && h < 12.5) return 'afternoon';
|
||||
if (h >= 16 && h < 19) return 'evening';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
// ── View modes (match #strat-row tabs) ───────────────────────────────────────────
|
||||
type ViewMode = 'kitchens' | 'zones' | 'riders' | 'all' | 'rider-info';
|
||||
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [
|
||||
{ id: 'kitchens', label: 'By Location', icon: MapPin },
|
||||
{ id: 'zones', label: 'By Zone', icon: MapIcon },
|
||||
{ id: 'riders', label: 'By Rider', icon: Bike },
|
||||
{ id: 'all', label: 'All Routes', icon: Globe },
|
||||
{ id: 'rider-info', label: 'Rider Info', icon: Info },
|
||||
];
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
orders: Row[];
|
||||
delivered: number;
|
||||
totalKm: number;
|
||||
profit: number;
|
||||
riders: Set<string>;
|
||||
suburbs: Map<string, number>;
|
||||
statusCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
interface DispatchViewProps {
|
||||
locationid?: number;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
export default function DispatchView({ locationid }: DispatchViewProps) {
|
||||
const today = new Date();
|
||||
const [date, setDate] = useState<string>(ymd(today));
|
||||
const [batch, setBatch] = useState<BatchId>(initialBatch());
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('riders');
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [tripSort, setTripSort] = useState<'planned' | 'time'>('planned');
|
||||
const [animateNonce, setAnimateNonce] = useState(0);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
|
||||
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate: date, todate: date });
|
||||
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
|
||||
|
||||
// Sample-data fallback: when the live feed returns nothing, render the demo set
|
||||
// so the cockpit isn't blank. The header labels it "Sample data" so it's never
|
||||
// mistaken for live (see services/dispatchMockData.ts).
|
||||
const liveRows = deliveriesQ.data ?? [];
|
||||
const usingMock = !deliveriesQ.isLoading && !deliveriesQ.isError && liveRows.length === 0;
|
||||
const allRows = usingMock ? MOCK_DELIVERIES : liveRows;
|
||||
// Sample rows aren't tied to the signed-in store, so skip the outlet filter for them.
|
||||
const inScope = (r: Row) => usingMock || !locationid || fnum(r.locationid) === locationid;
|
||||
|
||||
const rows = useMemo(
|
||||
() => allRows.filter((r) => inScope(r) && inBatch(r, batch)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[allRows, batch, locationid, usingMock],
|
||||
);
|
||||
|
||||
const batchCounts = useMemo(() => {
|
||||
const acc: Record<string, number> = { all: 0, morning: 0, afternoon: 0, evening: 0 };
|
||||
const scoped = allRows.filter(inScope);
|
||||
for (const b of BATCHES) acc[b.id] = scoped.filter((r) => inBatch(r, b.id)).length;
|
||||
return acc;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allRows, locationid, usingMock]);
|
||||
|
||||
// ── Grouping ────────────────────────────────────────────────────────────────
|
||||
const groups = useMemo<Group[]>(() => {
|
||||
const map = new Map<string, Group>();
|
||||
const keyOf = (r: Row): { id: string; name: string } => {
|
||||
if (viewMode === 'riders' || viewMode === 'rider-info') {
|
||||
const id = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
|
||||
return { id, name: fstr(r.ridername) || fstr(r.username) || (id === 'unassigned' ? 'Unassigned' : `Rider ${id}`) };
|
||||
}
|
||||
if (viewMode === 'kitchens') {
|
||||
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || 'Pickup';
|
||||
return { id: name.toLowerCase(), name };
|
||||
}
|
||||
if (viewMode === 'all') return { id: 'all', name: 'All Routes' };
|
||||
const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
|
||||
return { id: name.toLowerCase(), name };
|
||||
};
|
||||
for (const r of rows) {
|
||||
const { id, name } = keyOf(r);
|
||||
let g = map.get(id);
|
||||
if (!g) {
|
||||
g = { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {} };
|
||||
map.set(id, g);
|
||||
}
|
||||
g.orders.push(r);
|
||||
const st = fstr(r.orderstatus).toLowerCase();
|
||||
if (st === 'delivered') g.delivered += 1;
|
||||
g.statusCounts[st] = (g.statusCounts[st] ?? 0) + 1;
|
||||
g.totalKm += fnum(r.kms);
|
||||
g.profit += fnum(r.profit);
|
||||
const rid = fstr(r.userid) || fstr(r.ridername);
|
||||
if (rid) g.riders.add(rid);
|
||||
const sub = fstr(r.deliverysuburb);
|
||||
if (sub) g.suburbs.set(sub, (g.suburbs.get(sub) ?? 0) + 1);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => b.orders.length - a.orders.length);
|
||||
}, [rows, viewMode]);
|
||||
|
||||
const focused = groups.find((g) => g.id === focusedId) ?? null;
|
||||
const groupedByRider = viewMode === 'zones' || viewMode === 'kitchens' || viewMode === 'all';
|
||||
|
||||
// Trip blocks for the focused group: by trip# (rider view) or by rider (zone/all view).
|
||||
const tripBlocks = useMemo(() => {
|
||||
if (!focused) return [];
|
||||
const map = new Map<string, { label: string; color: string; orders: Row[] }>();
|
||||
for (const r of focused.orders) {
|
||||
let key: string;
|
||||
let label: string;
|
||||
let color: string;
|
||||
if (groupedByRider) {
|
||||
const rid = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
|
||||
key = rid;
|
||||
label = fstr(r.ridername) || fstr(r.username) || (rid === 'unassigned' ? 'Unassigned' : `Rider ${rid}`);
|
||||
color = colorFor(rid);
|
||||
} else {
|
||||
key = fstr(r.trip_number) || '1';
|
||||
label = `Trip ${key}`;
|
||||
color = focused.color;
|
||||
}
|
||||
let blk = map.get(key);
|
||||
if (!blk) { blk = { label, color, orders: [] }; map.set(key, blk); }
|
||||
blk.orders.push(r);
|
||||
}
|
||||
const blocks = Array.from(map.values());
|
||||
for (const blk of blocks) {
|
||||
blk.orders.sort((a, b) => {
|
||||
if (tripSort === 'time') {
|
||||
const ta = fstr(a.deliverytime) || fstr(a.expecteddeliverytime);
|
||||
const tb = fstr(b.deliverytime) || fstr(b.expecteddeliverytime);
|
||||
return ta.localeCompare(tb);
|
||||
}
|
||||
const sa = fnum(a.step);
|
||||
const sb = fnum(b.step);
|
||||
if (sa && sb && sa !== sb) return sa - sb;
|
||||
return fstr(a.assigntime).localeCompare(fstr(b.assigntime));
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}, [focused, groupedByRider, tripSort]);
|
||||
|
||||
// Map points: the focused group's ordered stops (with a route), else every stop
|
||||
// in the wave (coloured per rider). Rows without coordinates are skipped.
|
||||
const mapPoints = useMemo<MapPoint[]>(() => {
|
||||
const src = focused ? tripBlocks.flatMap((b) => b.orders) : rows;
|
||||
const out: MapPoint[] = [];
|
||||
src.forEach((r, i) => {
|
||||
const ll = dropLatLon(r);
|
||||
if (!ll) return;
|
||||
out.push({
|
||||
id: fstr(r.deliveryid) || fstr(r.orderid) || String(i),
|
||||
lat: ll[0],
|
||||
lon: ll[1],
|
||||
step: fnum(r.step) || i + 1,
|
||||
color: focused ? focused.color : colorFor(fstr(r.userid) || fstr(r.ridername) || 'x'),
|
||||
title: fstr(r.deliverycustomer) || `Order ${fstr(r.orderid)}`,
|
||||
subtitle: fstr(r.deliverysuburb) || fstr(r.deliveryaddress),
|
||||
status: fstr(r.orderstatus),
|
||||
raw: r,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}, [focused, tripBlocks, rows]);
|
||||
|
||||
// Route start = the focused group's pickup/hub (so the road route originates there).
|
||||
const firstOrder = tripBlocks[0]?.orders[0] ?? focused?.orders[0];
|
||||
const routeStart = focused && firstOrder ? pickupLatLon(firstOrder) : null;
|
||||
|
||||
// KPI scope.
|
||||
const totalOrders = rows.length;
|
||||
const activeRiders = new Set(rows.map((r) => fstr(r.userid) || fstr(r.ridername)).filter(Boolean)).size;
|
||||
const fleetSize = usingMock ? MOCK_RIDERS.length : (ridersQ.data ?? []).length;
|
||||
const scopeLabel = BATCHES.find((b) => b.id === batch)?.label ?? 'All';
|
||||
|
||||
// Date chip helpers.
|
||||
const isToday = date === ymd(today);
|
||||
const dateObj = new Date(`${date}T00:00:00`);
|
||||
const prettyDate = `${WEEKDAYS[dateObj.getDay()]}, ${dateObj.getDate()} ${MONTHS[dateObj.getMonth()]}`;
|
||||
const shiftDate = (delta: number) => {
|
||||
const d = new Date(`${date}T00:00:00`);
|
||||
d.setDate(d.getDate() + delta);
|
||||
if (d > today) return;
|
||||
setDate(ymd(d));
|
||||
setFocusedId(null);
|
||||
};
|
||||
|
||||
const fmtTime = (raw: unknown): string => {
|
||||
const m = fstr(raw).match(/(\d{1,2}):(\d{2})/);
|
||||
return m ? `${m[1]}:${m[2]}` : '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0 }}>
|
||||
<div className="dispatch-container embedded">
|
||||
{/* ── Header ── */}
|
||||
<div id="hdr">
|
||||
<div className="logo">
|
||||
<div className="logo-badge">D</div>
|
||||
<div className="logo-name">Dispatch</div>
|
||||
<div className="logo-city-wrap">
|
||||
<span className="logo-city" style={{ cursor: 'default' }}>
|
||||
<MapPin size={13} />
|
||||
<span className="logo-city-text">Coimbatore</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hdr-stats">
|
||||
{deliveriesQ.isLoading ? (
|
||||
<span className="live-status">
|
||||
<span className="live-dot" /> Syncing
|
||||
</span>
|
||||
) : deliveriesQ.isError ? (
|
||||
<span className="live-status live-status-error">
|
||||
<span className="live-dot error" /> Offline
|
||||
</span>
|
||||
) : usingMock ? (
|
||||
<span className="live-status" title="No live deliveries for this day — showing sample data">
|
||||
<span className="live-dot" style={{ background: '#f59e0b' }} /> Sample data · {totalOrders} orders
|
||||
</span>
|
||||
) : (
|
||||
<span className="live-status live-status-ready">
|
||||
<span className="live-dot ready" /> Live · {totalOrders} orders
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className={`date-chip${isToday ? ' is-today' : ''}`}>
|
||||
<button className="date-chip-nav" onClick={() => shiftDate(-1)} title="Previous day">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<div className="date-chip-main" style={{ position: 'relative' }}>
|
||||
<span className="date-chip-icon"><Calendar size={14} /></span>
|
||||
<span className="date-chip-text">
|
||||
<span className="date-chip-label">
|
||||
Date {isToday && <span className="date-chip-today-pill">Today</span>}
|
||||
</span>
|
||||
<span className="date-chip-value">{prettyDate}</span>
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
max={ymd(today)}
|
||||
onChange={(e) => { setDate(e.target.value); setFocusedId(null); }}
|
||||
style={{ position: 'absolute', inset: 0, opacity: 0, cursor: 'pointer', width: '100%', height: '100%' }}
|
||||
aria-label="Pick date"
|
||||
/>
|
||||
</div>
|
||||
<button className="date-chip-nav" onClick={() => shiftDate(1)} disabled={isToday} title="Next day">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── View-mode tabs ── */}
|
||||
<div id="strat-row">
|
||||
{VIEW_TABS.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`sbt ${viewMode === t.id ? 'active' : ''}${t.id === 'rider-info' ? ' sbt-rider-info' : ''}`}
|
||||
onClick={() => { setViewMode(t.id); setFocusedId(null); }}
|
||||
>
|
||||
<span className="sbt-icon"><Icon size={15} /></span>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Batch / wave bar ── */}
|
||||
<div id="batch-row">
|
||||
<span className="batch-label">Batch</span>
|
||||
<div className="batch-scroll">
|
||||
{BATCHES.map((b) => (
|
||||
<button
|
||||
key={b.id}
|
||||
className={`batch-btn batch-slot ${batch === b.id ? 'active' : ''}`}
|
||||
onClick={() => { setBatch(b.id); setFocusedId(null); }}
|
||||
title={`${b.label} (${b.range})`}
|
||||
>
|
||||
<span className="batch-btn-label">{b.label}</span>
|
||||
<span className="batch-btn-count">{batchCounts[b.id] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}>
|
||||
<button
|
||||
className={`sidebar-toggle-tab${sidebarCollapsed ? ' is-collapsed' : ''}`}
|
||||
onClick={() => setSidebarCollapsed((c) => !c)}
|
||||
title={sidebarCollapsed ? 'Show panel' : 'Hide panel'}
|
||||
>
|
||||
{sidebarCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div id="sidebar">
|
||||
<div className="sb-header">
|
||||
<div className="sb-header-top">
|
||||
<div className="sb-header-title">
|
||||
<span className="sb-title-bar" aria-hidden="true" />
|
||||
<span className="sb-title-text">RIDER DISPATCH</span>
|
||||
</div>
|
||||
<span className="sb-header-scope">
|
||||
<span className="sb-scope-dot" />
|
||||
{scopeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sb-header-tiles">
|
||||
<div className="sb-tile sb-tile-orders">
|
||||
<span className="sb-tile-icon"><Package size={16} /></span>
|
||||
<div className="sb-tile-body">
|
||||
<div className="sb-tile-value">{totalOrders}</div>
|
||||
<div className="sb-tile-label">Orders</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sb-tile sb-tile-riders">
|
||||
<span className="sb-tile-icon"><Bike size={16} /></span>
|
||||
<div className="sb-tile-body">
|
||||
<div className="sb-tile-value">{activeRiders}{fleetSize ? `/${fleetSize}` : ''}</div>
|
||||
<div className="sb-tile-label">Riders</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="riders-panel">
|
||||
{deliveriesQ.isLoading ? (
|
||||
<div className="ph">Loading dispatch feed…</div>
|
||||
) : focused ? (
|
||||
<FocusedDetail
|
||||
focused={focused}
|
||||
tripBlocks={tripBlocks}
|
||||
groupedByRider={groupedByRider}
|
||||
tripSort={tripSort}
|
||||
setTripSort={setTripSort}
|
||||
onBack={() => setFocusedId(null)}
|
||||
fmtTime={fmtTime}
|
||||
/>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="ph">No deliveries in this wave</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ph">
|
||||
{viewMode === 'riders' || viewMode === 'rider-info' ? 'Riders' : viewMode === 'kitchens' ? 'Pickup points' : viewMode === 'all' ? 'All routes' : 'Zones'} ({groups.length})
|
||||
</div>
|
||||
{groups.map((g) => (
|
||||
<React.Fragment key={g.id}>
|
||||
{viewMode === 'riders' || viewMode === 'rider-info'
|
||||
? <RiderCard g={g} onClick={() => setFocusedId(g.id)} />
|
||||
: <ZoneCard g={g} onClick={() => setFocusedId(g.id)} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map area */}
|
||||
<div id="map-wrap">
|
||||
{/* Live Leaflet route map */}
|
||||
<DispatchMap
|
||||
points={mapPoints}
|
||||
route={Boolean(focused)}
|
||||
routeColor={focused?.color || '#581c87'}
|
||||
start={routeStart}
|
||||
resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}|${batch}`}
|
||||
animateNonce={animateNonce}
|
||||
/>
|
||||
|
||||
{/* Contextual note overlaid on the map */}
|
||||
{viewMode === 'rider-info' ? (
|
||||
<div className="dmp-overlay-note">
|
||||
<PlugZap size={13} /> Live rider telemetry (battery · GPS · speed) awaiting backend — map shows planned drops.
|
||||
</div>
|
||||
) : mapPoints.length === 0 ? (
|
||||
<div className="dmp-overlay-note">
|
||||
<MapIcon size={13} /> No drop coordinates in this {focused ? 'route' : 'wave'} yet.
|
||||
</div>
|
||||
) : !focused ? (
|
||||
<div className="dmp-overlay-note">
|
||||
<MapIcon size={13} /> Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : 'rider'} to draw its route.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* bottom-right overlay controls (gated) */}
|
||||
<div id="ov-br">
|
||||
<button
|
||||
className={`sbt ${animating ? 'active' : ''}`}
|
||||
disabled={!focused || mapPoints.length < 2}
|
||||
onClick={() => {
|
||||
if (!focused || mapPoints.length < 2) return;
|
||||
setAnimating(true);
|
||||
setAnimateNonce((n) => n + 1);
|
||||
window.setTimeout(() => setAnimating(false), 2300);
|
||||
}}
|
||||
title={focused ? 'Replay the route draw' : 'Select a rider to animate its route'}
|
||||
>
|
||||
<span className="sbt-icon"><Play size={14} /></span> {animating ? 'Animating…' : 'Animate Routes'}
|
||||
</button>
|
||||
<button className="sbt" disabled title="Planned-vs-actual compare needs rider GPS telemetry (awaiting backend)">
|
||||
<span className="sbt-icon"><ArrowLeftRight size={14} /></span> Compare
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Rider card ───────────────────────────────────────────────────────────────────
|
||||
function RiderCard({ g, onClick }: { g: Group; onClick: () => void }) {
|
||||
const total = g.orders.length;
|
||||
const percent = total ? Math.round((g.delivered / total) * 100) : 0;
|
||||
const isDone = total > 0 && g.delivered === total;
|
||||
const zoneName = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || 'Mixed';
|
||||
const trips = new Set(g.orders.map((o) => fstr(o.trip_number) || '1')).size;
|
||||
return (
|
||||
<div className="rcard" onClick={onClick}>
|
||||
<div className="rcard-top">
|
||||
<div className="rcard-emo" style={{ background: `${g.color}18`, color: g.color }}>
|
||||
<Bike size={18} />
|
||||
</div>
|
||||
<div className="rcard-info">
|
||||
<div className="rcard-name">{g.name}</div>
|
||||
<div className="rcard-zone">{zoneName} · {trips} trip{trips > 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<div className={`rcard-badge ${isDone ? 'is-done' : ''}`}>{g.delivered}/{total}</div>
|
||||
</div>
|
||||
<div className="bar-bg">
|
||||
<div className="bar-fg" style={{ width: `${percent}%`, background: g.color }} />
|
||||
</div>
|
||||
<div className="rcard-meta">
|
||||
<span><Ruler size={11} /> {g.totalKm.toFixed(1)} km</span>
|
||||
{g.profit > 0 && <span><Wallet size={11} /> ₹{g.profit.toLocaleString('en-IN')}</span>}
|
||||
</div>
|
||||
<div className="step-ids">
|
||||
{g.orders.slice(0, 16).map((o, i) => (
|
||||
<span key={fstr(o.orderid) || i} className="step-id">S{fnum(o.step) || i + 1}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Zone card (also used for By Location / All Routes) ───────────────────────────
|
||||
function ZoneCard({ g, onClick }: { g: Group; onClick: () => void }) {
|
||||
const suburbs = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1]).map(([s]) => s);
|
||||
return (
|
||||
<div className="rcard zone-card" onClick={onClick}>
|
||||
<div className="zone-card-header">
|
||||
<div className="zone-card-emoji" style={{ color: g.color }}><MapIcon size={16} /></div>
|
||||
<div className="zone-card-titles">
|
||||
<div className="zone-card-name">{g.name}</div>
|
||||
<div className="zone-card-sub">{g.riders.size} rider{g.riders.size === 1 ? '' : 's'} · {g.orders.length} orders</div>
|
||||
</div>
|
||||
<span className="zone-card-arrow" aria-hidden="true">→</span>
|
||||
</div>
|
||||
{g.orders.length > 0 && (
|
||||
<div className="zone-progress-row">
|
||||
<div className="zone-status-bar">
|
||||
{Object.entries(g.statusCounts).map(([s, c]) => (
|
||||
<div key={s} className="zone-status-seg" style={{ flex: c, background: STATUS_HEX[s] || '#cbd5e1' }} title={`${s}: ${c}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="zone-progress-label">{g.delivered}/{g.orders.length}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="zone-stat-pills">
|
||||
<span className="zone-stat-pill">
|
||||
<span className="zone-stat-icon"><MapPin size={12} /></span>
|
||||
<span className="zone-stat-value">{g.suburbs.size}</span>
|
||||
<span className="zone-stat-label">areas</span>
|
||||
</span>
|
||||
<span className="zone-stat-pill">
|
||||
<span className="zone-stat-icon"><Ruler size={12} /></span>
|
||||
<span className="zone-stat-value">{g.totalKm.toFixed(0)}</span>
|
||||
<span className="zone-stat-label">km</span>
|
||||
</span>
|
||||
{g.profit > 0 && (
|
||||
<span className="zone-stat-pill">
|
||||
<span className="zone-stat-icon"><Wallet size={12} /></span>
|
||||
<span className="zone-stat-value">₹{g.profit.toLocaleString('en-IN')}</span>
|
||||
<span className="zone-stat-label">profit</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{suburbs.length > 0 && (
|
||||
<div className="zone-card-suburbs">
|
||||
<span className="zone-card-suburbs-text">{suburbs.slice(0, 3).join(' · ')}</span>
|
||||
{suburbs.length > 3 && <span className="zone-card-suburbs-more">+{suburbs.length - 3}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Focused detail (trip blocks + order cards) ───────────────────────────────────
|
||||
function FocusedDetail({
|
||||
focused,
|
||||
tripBlocks,
|
||||
groupedByRider,
|
||||
tripSort,
|
||||
setTripSort,
|
||||
onBack,
|
||||
fmtTime,
|
||||
}: {
|
||||
focused: Group;
|
||||
tripBlocks: Array<{ label: string; color: string; orders: Row[] }>;
|
||||
groupedByRider: boolean;
|
||||
tripSort: 'planned' | 'time';
|
||||
setTripSort: (v: 'planned' | 'time') => void;
|
||||
onBack: () => void;
|
||||
fmtTime: (raw: unknown) => string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<button className="sbt" onClick={onBack} style={{ marginBottom: 12 }}>
|
||||
<span className="sbt-icon"><ChevronLeft size={15} /></span> Back to list
|
||||
</button>
|
||||
|
||||
{tripBlocks.map((blk, bi) => (
|
||||
<div className="trip-block" key={bi}>
|
||||
<div className="trip-header" style={{ background: `${blk.color}12`, borderColor: `${blk.color}40` }}>
|
||||
<span className="th-badge" style={{ background: blk.color }}>{blk.label}</span>
|
||||
<span className="trip-stats">
|
||||
<span><MapPin size={11} /> {blk.orders.length} stops</span>
|
||||
<span><Ruler size={11} /> {blk.orders.reduce((a, o) => a + fnum(o.kms), 0).toFixed(1)} km</span>
|
||||
</span>
|
||||
<div className="trip-sort-toggle" role="group">
|
||||
<button className={`trip-sort-pill ${tripSort === 'planned' ? 'is-active' : ''}`} onClick={() => setTripSort('planned')}>
|
||||
<List size={12} /> <span>Planned</span>
|
||||
</button>
|
||||
<button className={`trip-sort-pill ${tripSort === 'time' ? 'is-active' : ''}`} onClick={() => setTripSort('time')}>
|
||||
<Clock size={12} /> <span>By time</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="zone-order-grid">
|
||||
{blk.orders.map((o, i) => {
|
||||
const st = fstr(o.orderstatus).toLowerCase();
|
||||
const step = fnum(o.step) || i + 1;
|
||||
const actual = fstr(o.deliverytime);
|
||||
const expected = fstr(o.expecteddeliverytime);
|
||||
const profit = fnum(o.profit);
|
||||
const km = fnum(o.kms);
|
||||
const charge = fnum(o.deliverycharge) || fnum(o.deliverycharges);
|
||||
return (
|
||||
<div className={`zone-order-card ${st === 'delivered' ? '' : 'is-pending-time'}`} key={fstr(o.deliveryid) || fstr(o.orderid) || i}>
|
||||
<div className="zone-order-card-head">
|
||||
<div className="zone-order-num" style={{ background: `${blk.color}15`, color: blk.color }}>{step}</div>
|
||||
<div className="zone-order-id-block">
|
||||
<div className="zone-order-id">Order #{fstr(o.orderid) || fstr(o.deliveryid)}</div>
|
||||
{groupedByRider && fstr(o.ridername) && (
|
||||
<div className="zone-order-rider"><Bike size={10} /> {fstr(o.ridername)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="zone-order-status-stack">
|
||||
{st && <span className="zone-order-status" style={statusStyle(st)}>{st}</span>}
|
||||
{(actual || expected) && (
|
||||
<span className={`zone-order-time ${actual ? '' : 'is-expected'}`}>
|
||||
<Clock size={10} /> {fmtTime(actual || expected)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="zone-order-customer"><Mailbox size={11} /> {fstr(o.deliverycustomer) || 'Customer'}</div>
|
||||
{fstr(o.pickupcustomer) && (
|
||||
<div className="zone-order-line"><Utensils size={11} /> {fstr(o.pickupcustomer)}</div>
|
||||
)}
|
||||
{(fstr(o.deliverysuburb) || fstr(o.deliveryaddress)) && (
|
||||
<div className="zone-order-line"><MapPin size={11} /> {fstr(o.deliverysuburb) || fstr(o.deliveryaddress)}</div>
|
||||
)}
|
||||
{fstr(o.ordernotes) && (
|
||||
<div className="zone-order-line zone-order-notes"><StickyNote size={11} /> {fstr(o.ordernotes)}</div>
|
||||
)}
|
||||
|
||||
<div className="zone-order-stats">
|
||||
<span className="zone-order-chip"><Ruler size={10} /> {km.toFixed(1)} km</span>
|
||||
{profit !== 0 && (
|
||||
<span className={`zone-order-chip ${profit < 0 ? 'is-loss' : 'is-profit'}`}>
|
||||
<Wallet size={10} /> ₹{Math.abs(profit).toLocaleString('en-IN')}
|
||||
</span>
|
||||
)}
|
||||
{charge > 0 && <span className="zone-order-chip">₹{charge} chg</span>}
|
||||
<span className="zone-order-chip zone-order-trip"><Crosshair size={10} /> S{step}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
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';
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
LayoutDashboard,
|
||||
Store,
|
||||
Layers,
|
||||
ShoppingBag,
|
||||
Settings,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
|
||||
403
src/components/StoreCatalogView.tsx
Normal file
403
src/components/StoreCatalogView.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Inventory & Catalog — the store user's page.
|
||||
*
|
||||
* Flow: the manager curates an assortment from the global catalog; the store user
|
||||
* sees ONLY that manager-selected catalog (never the global one) and chooses which
|
||||
* products to stock in their own store. Two tabs:
|
||||
* • Browse Catalog — the manager-approved products, each addable to the store.
|
||||
* • My Store Inventory — what's currently stocked at this outlet (live stock).
|
||||
*
|
||||
* The "manager-selected catalog" is sourced from the tenant master catalog
|
||||
* (getMasterCatalog) for now — see CATALOG_SOURCE below; swap that one hook for
|
||||
* the approved-products endpoint once it exists.
|
||||
*
|
||||
* Stocking a product at a location needs a write endpoint that isn't built yet,
|
||||
* so selections are kept locally (persisted per store) and marked "pending sync".
|
||||
* `commitSelectionToStore()` is the single integration point: replace its body
|
||||
* with the real mutation when the backend is ready.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Search, Boxes, Layers, Plus, Check, CheckCircle2, X, Tag, Store, PackageSearch, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useFiestaMasterCatalog,
|
||||
useFiestaStockStatement,
|
||||
useFiestaProductCategories,
|
||||
useFiestaProductSubcategories,
|
||||
FIESTA_TENANT_ID,
|
||||
} from '../services/fiestaQueries';
|
||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||
import { categoryName } from '../services/fiestaMappers';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
|
||||
const BRAND = '#581c87';
|
||||
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
||||
|
||||
interface StoreCatalogViewProps {
|
||||
locationid?: number;
|
||||
storeName?: string;
|
||||
}
|
||||
|
||||
interface CatalogProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
category: string;
|
||||
categoryid: number;
|
||||
subcategoryid: number;
|
||||
subcategoryname: string;
|
||||
price: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
function stockStatus(closing: number): { label: string; color: string } {
|
||||
if (closing <= 0) return { label: 'Out of stock', color: '#ef4444' };
|
||||
if (closing < 25) return { label: 'Critical', color: '#ef4444' };
|
||||
if (closing < 120) return { label: 'Low', color: '#f59e0b' };
|
||||
return { label: 'Healthy', color: '#10b981' };
|
||||
}
|
||||
|
||||
export default function StoreCatalogView({ locationid, storeName = 'your store' }: StoreCatalogViewProps) {
|
||||
const tenantid = FIESTA_TENANT_ID;
|
||||
const [view, setView] = useState<'catalog' | 'inventory'>('catalog');
|
||||
const [search, setSearch] = useState('');
|
||||
const [categoryid, setCategoryid] = useState(0);
|
||||
const [subcategoryid, setSubcategoryid] = useState(0);
|
||||
const [notice, setNotice] = useState(false);
|
||||
|
||||
// Selections "to stock at this store" — persisted per outlet so choices survive
|
||||
// a refresh until the backend write exists.
|
||||
const storageKey = `nearledaily.catalog.selected.${locationid ?? 'na'}`;
|
||||
const [selected, setSelected] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
return new Set(raw ? (JSON.parse(raw) as string[]) : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(storageKey, JSON.stringify([...selected])); } catch { /* ignore */ }
|
||||
}, [selected, storageKey]);
|
||||
|
||||
// ── Data ──────────────────────────────────────────────────────────────────────
|
||||
// CATALOG_SOURCE: the manager-selected assortment. Swap this hook for the
|
||||
// approved-products endpoint when it's available; the rest of the page is agnostic.
|
||||
const catalogQ = useFiestaMasterCatalog({ tenantid, subcategoryid: subcategoryid || undefined, pagesize: 200 });
|
||||
const stockQ = useFiestaStockStatement({ tenantid, locationid: locationid ?? 0, pagesize: 200 });
|
||||
const categoriesQ = useFiestaProductCategories();
|
||||
const subcategoriesQ = useFiestaProductSubcategories({ categoryid, tenantid });
|
||||
|
||||
const products = useMemo<CatalogProduct[]>(
|
||||
() =>
|
||||
(catalogQ.data ?? []).map((r: Row) => ({
|
||||
id: fstr(r.productid) || fstr(r.productname),
|
||||
name: fstr(r.productname) || 'Unnamed product',
|
||||
image: fstr(r.productimage) || PLACEHOLDER,
|
||||
category: categoryName(fnum(r.categoryid)),
|
||||
categoryid: fnum(r.categoryid),
|
||||
subcategoryid: fnum(r.subcategoryid),
|
||||
subcategoryname: fstr(r.subcategoryname),
|
||||
price: fnum(r.retailprice) || fnum(r.productcost),
|
||||
unit: `${fstr(r.productunit) || 'unit'} · ${fstr(r.unitvalue) || '1'}`,
|
||||
})),
|
||||
[catalogQ.data],
|
||||
);
|
||||
|
||||
// Products already stocked at this store (by productid) — drives the "In Store" state.
|
||||
const inStore = useMemo(() => new Set((stockQ.data ?? []).map((r) => fstr(r.productid))), [stockQ.data]);
|
||||
|
||||
const inventory = useMemo(
|
||||
() =>
|
||||
(stockQ.data ?? []).map((r: Row) => {
|
||||
const closing = fnum(r.closing);
|
||||
return {
|
||||
id: fstr(r.productid),
|
||||
name: fstr(r.productname) || 'Unnamed product',
|
||||
category: categoryName(fnum(r.categoryid)),
|
||||
closing,
|
||||
...stockStatus(closing),
|
||||
};
|
||||
}),
|
||||
[stockQ.data],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = search.toLowerCase();
|
||||
return products.filter((p) => {
|
||||
if (categoryid && p.categoryid !== categoryid) return false;
|
||||
if (!term) return true;
|
||||
return p.name.toLowerCase().includes(term) || p.category.toLowerCase().includes(term) || p.id.toLowerCase().includes(term);
|
||||
});
|
||||
}, [products, search, categoryid]);
|
||||
|
||||
// Categories come from the Fiesta product-categories endpoint; if it returns
|
||||
// nothing, fall back to the categories present in the loaded catalog so the
|
||||
// filter is never empty.
|
||||
const categories = useMemo(() => {
|
||||
const fromApi = (categoriesQ.data ?? [])
|
||||
.map((c) => ({ id: fnum(c.categoryid), name: fstr(c.categoryname) || categoryName(fnum(c.categoryid)) }))
|
||||
.filter((c) => c.id);
|
||||
if (fromApi.length) return fromApi;
|
||||
const seen = new Map<number, string>();
|
||||
for (const p of products) if (p.categoryid && !seen.has(p.categoryid)) seen.set(p.categoryid, p.category);
|
||||
return [...seen.entries()].map(([id, name]) => ({ id, name }));
|
||||
}, [categoriesQ.data, products]);
|
||||
// Subcategories: Fiesta endpoint as source of truth; fall back to the
|
||||
// subcategories present in the loaded catalog for the selected category.
|
||||
const subcategories = useMemo(() => {
|
||||
const fromApi = (subcategoriesQ.data ?? [])
|
||||
.map((s) => ({ id: fnum(s.subcategoryid), name: fstr(s.subcategoryname) || `Subcategory ${fnum(s.subcategoryid)}` }))
|
||||
.filter((s) => s.id);
|
||||
if (fromApi.length) return fromApi;
|
||||
const seen = new Map<number, string>();
|
||||
for (const p of products) {
|
||||
if (categoryid && p.categoryid !== categoryid) continue;
|
||||
if (p.subcategoryid && !seen.has(p.subcategoryid)) seen.set(p.subcategoryid, p.subcategoryname || `Subcategory ${p.subcategoryid}`);
|
||||
}
|
||||
return [...seen.entries()].map(([id, name]) => ({ id, name }));
|
||||
}, [subcategoriesQ.data, products, categoryid]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setNotice(false);
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ── Integration point ──────────────────────────────────────────────────────────
|
||||
// Replace this body with the real mutation: POST the selected product ids to the
|
||||
// store/location assortment (stock-entry) endpoint, then invalidate stockQ.
|
||||
const commitSelectionToStore = () => {
|
||||
setNotice(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-24">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">Inventory & Catalog</h1>
|
||||
<p className="text-zinc-500 text-xs mt-1">
|
||||
Browse the products approved for your store and choose what to stock at <span className="font-semibold text-[#581c87]">{storeName}</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-zinc-100/80 p-1 rounded-xl border border-zinc-200/60 w-full sm:w-auto sm:inline-flex">
|
||||
<button
|
||||
onClick={() => setView('catalog')}
|
||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-xs font-bold transition-all ${
|
||||
view === 'catalog' ? 'bg-white text-[#581c87] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Boxes size={14} /> Browse Catalog ({products.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('inventory')}
|
||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-xs font-bold transition-all ${
|
||||
view === 'inventory' ? 'bg-white text-[#581c87] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Store size={14} /> My Store Inventory ({inventory.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-md bg-white border border-[#eceef2] p-md rounded-2xl shadow-sm">
|
||||
<div className="relative w-full md:w-80 md:shrink-0">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={view === 'catalog' ? 'Search catalog products…' : 'Search your stock…'}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-9 py-2.5 border border-[#e2e8f0] rounded-xl text-xs outline-none bg-[#f8fafc] focus:bg-white focus:ring-1 focus:ring-[#581c87] transition-all"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600">
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === 'catalog' && (
|
||||
<div className="flex items-center gap-sm flex-wrap">
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 uppercase tracking-widest">
|
||||
<Layers size={13} className="text-[#581c87]" /> Filter
|
||||
</span>
|
||||
<select
|
||||
value={categoryid}
|
||||
onChange={(e) => { setCategoryid(Number(e.target.value)); setSubcategoryid(0); }}
|
||||
className="border border-[#e2e8f0] rounded-lg px-3 py-2 text-xs font-semibold text-zinc-700 bg-[#f8fafc] outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
|
||||
>
|
||||
<option value={0}>All categories</option>
|
||||
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
{categoryid > 0 && subcategories.length > 0 && (
|
||||
<select
|
||||
value={subcategoryid}
|
||||
onChange={(e) => setSubcategoryid(Number(e.target.value))}
|
||||
className="border border-[#e2e8f0] rounded-lg px-3 py-2 text-xs font-semibold text-zinc-700 bg-[#f8fafc] outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
|
||||
>
|
||||
<option value={0}>All subcategories</option>
|
||||
{subcategories.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="md:ml-auto text-[11px] font-semibold text-zinc-400">
|
||||
{view === 'catalog' ? `${filtered.length} products` : `${inventory.length} stocked`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Browse Catalog ── */}
|
||||
{view === 'catalog' && (
|
||||
catalogQ.isLoading ? (
|
||||
<CenterState icon={<PackageSearch size={26} />} title="Loading catalog…" />
|
||||
) : catalogQ.isError ? (
|
||||
<CenterState icon={<AlertTriangle size={26} />} title="Couldn't load the catalog" sub="Check your connection and try again." tone="error" />
|
||||
) : filtered.length === 0 ? (
|
||||
<CenterState icon={<Boxes size={26} />} title="No products found" sub="Your manager hasn't approved products matching this filter yet." />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-gutter">
|
||||
{filtered.map((p) => {
|
||||
const stocked = inStore.has(p.id);
|
||||
const isSelected = selected.has(p.id);
|
||||
return (
|
||||
<div key={p.id} className="group bg-white border border-[#e2e8f0] rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-all flex flex-col">
|
||||
<div className="relative h-28 w-full overflow-hidden bg-zinc-50">
|
||||
<img src={p.image} alt={p.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
||||
{stocked && (
|
||||
<span className="absolute top-2 right-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500 text-white text-[9px] font-bold uppercase tracking-wide shadow">
|
||||
<CheckCircle2 size={10} /> In Store
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 flex-1 flex flex-col">
|
||||
<span className="inline-flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-[#581c87] mb-1">
|
||||
<Tag size={9} /> {p.category}
|
||||
</span>
|
||||
<p className="font-bold text-xs text-[#0f172a] leading-snug line-clamp-2 min-h-[2rem]">{p.name}</p>
|
||||
<div className="flex items-center justify-between mt-1.5 mb-3">
|
||||
<span className="font-mono font-extrabold text-sm text-zinc-800">{p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}</span>
|
||||
<span className="text-[9px] text-zinc-400 font-semibold">{p.unit}</span>
|
||||
</div>
|
||||
|
||||
{stocked ? (
|
||||
<button disabled className="mt-auto w-full py-2 rounded-xl text-[11px] font-bold bg-emerald-50 text-emerald-600 border border-emerald-100 cursor-default flex items-center justify-center gap-1.5">
|
||||
<CheckCircle2 size={13} /> Stocked
|
||||
</button>
|
||||
) : isSelected ? (
|
||||
<button onClick={() => toggle(p.id)} className="mt-auto w-full py-2 rounded-xl text-[11px] font-bold bg-[#581c87] text-white hover:bg-purple-800 transition flex items-center justify-center gap-1.5 cursor-pointer">
|
||||
<Check size={13} /> Selected
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => toggle(p.id)} className="mt-auto w-full py-2 rounded-xl text-[11px] font-bold bg-white text-[#581c87] border border-purple-200 hover:bg-purple-50 transition flex items-center justify-center gap-1.5 cursor-pointer">
|
||||
<Plus size={13} /> Add to Store
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ── My Store Inventory ── */}
|
||||
{view === 'inventory' && (
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-2xl shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-[#f8fafc] border-b border-[#e2e8f0] text-[10px] uppercase tracking-wider text-zinc-400 font-bold">
|
||||
<th className="px-4 py-3 text-left">#</th>
|
||||
<th className="px-4 py-3 text-left">Product</th>
|
||||
<th className="px-4 py-3 text-left">Category</th>
|
||||
<th className="px-4 py-3 text-right">In Stock</th>
|
||||
<th className="px-4 py-3 text-center">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#f1f5f9]">
|
||||
{stockQ.isLoading ? (
|
||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-zinc-400">Loading your stock…</td></tr>
|
||||
) : !locationid ? (
|
||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-zinc-400">No store linked to your account yet.</td></tr>
|
||||
) : inventory.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-zinc-400">No products stocked yet — add some from the catalog.</td></tr>
|
||||
) : (
|
||||
inventory.map((it, i) => (
|
||||
<tr key={it.id || i} className="hover:bg-zinc-50/70 transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-zinc-400">{i + 1}</td>
|
||||
<td className="px-4 py-3 font-bold text-[#0f172a]">{it.name}</td>
|
||||
<td className="px-4 py-3 text-zinc-500">{it.category}</td>
|
||||
<td className="px-4 py-3 text-right font-mono font-bold text-zinc-700">{it.closing.toLocaleString('en-IN')}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border" style={{ background: `${it.color}14`, color: it.color, borderColor: `${it.color}40` }}>
|
||||
{it.label}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Selection action bar (sticky) ── */}
|
||||
{view === 'catalog' && selected.size > 0 && (
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[120] w-[min(640px,calc(100vw-2rem))]">
|
||||
<div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-4 py-3">
|
||||
{notice ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold">{selected.size} product{selected.size > 1 ? 's' : ''} marked for {storeName}</span>
|
||||
<button onClick={() => { setSelected(new Set()); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer">Clear</button>
|
||||
</div>
|
||||
<AwaitingApi label="Adding products to your store" api="stock-entry API" compact className="bg-white/5 border-white/15 text-purple-100" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center shrink-0"><Boxes size={15} /></span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold truncate">{selected.size} product{selected.size > 1 ? 's' : ''} selected</p>
|
||||
<p className="text-[10px] text-purple-200">Ready to stock at {storeName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={() => setSelected(new Set())} className="px-3 py-2 rounded-xl text-[11px] font-bold text-purple-200 hover:text-white hover:bg-white/10 transition cursor-pointer">Clear</button>
|
||||
<button onClick={commitSelectionToStore} className="px-4 py-2 rounded-xl text-[11px] font-bold bg-white text-[#581c87] hover:bg-purple-50 transition cursor-pointer flex items-center gap-1.5">
|
||||
<Plus size={13} /> Add to Store
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CenterState({ icon, title, sub, tone }: { icon: React.ReactNode; title: string; sub?: string; tone?: 'error' }) {
|
||||
return (
|
||||
<div className="bg-white border border-dashed border-[#e2e8f0] rounded-2xl p-12 text-center">
|
||||
<div className={`mx-auto mb-3 flex items-center justify-center w-14 h-14 rounded-2xl ${tone === 'error' ? 'bg-rose-50 text-rose-500' : 'bg-zinc-100 text-zinc-400'}`}>{icon}</div>
|
||||
<p className="font-bold text-sm text-zinc-700">{title}</p>
|
||||
{sub && <p className="text-xs text-zinc-400 mt-1">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,8 +30,7 @@ import {
|
||||
CreditCard,
|
||||
History,
|
||||
Building,
|
||||
Award,
|
||||
ShoppingBag
|
||||
Award
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useFiestaStockStatement,
|
||||
@@ -44,7 +43,6 @@ import {
|
||||
import { str as fstr, num as fnum } from '../services/fiestaApi';
|
||||
import { mapOrderStatus, shortTime } from '../services/fiestaMappers';
|
||||
import ragulStoreCover from '../assets/images/store_front_view_1780299351800.png';
|
||||
import OrdersDeliveriesView from './OrdersDeliveriesView';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
|
||||
interface StoreDetailViewProps {
|
||||
@@ -66,6 +64,10 @@ interface StoreDetailViewProps {
|
||||
* catalogue, promo/credit). Admins get true; plain store users get false so
|
||||
* the console is view-only. */
|
||||
canManage?: boolean;
|
||||
/** Render a single section only (no tab bar). The user store console splits
|
||||
* Overview, Inventory & Catalogue, and Customers into separate pages. When
|
||||
* omitted, the full tabbed console renders (admin store detail). */
|
||||
only?: 'overview' | 'inventory' | 'customers';
|
||||
}
|
||||
|
||||
// Fallback cover images
|
||||
@@ -84,8 +86,13 @@ const DETAIL_STORE_COVERS = [
|
||||
'https://images.unsplash.com/photo-1579621970563-ebec7560ff3e?auto=format&fit=crop&w=800&q=80'
|
||||
];
|
||||
|
||||
export default function StoreDetailView({ store, onBack, canManage = true }: StoreDetailViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers' | 'orders'>('overview');
|
||||
export default function StoreDetailView({ store, onBack, canManage = true, only }: StoreDetailViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers'>('overview');
|
||||
// Which section to show: forced by `only` (separate-page mode) or the active tab.
|
||||
const section = only ?? activeTab;
|
||||
// The immersive store banner shows on Overview (and the admin tabbed console);
|
||||
// the standalone Inventory & Customers pages omit it.
|
||||
const showHero = !only || only === 'overview';
|
||||
|
||||
const isRagul = store.name.toLowerCase().includes('ragul');
|
||||
const getStoreCover = () => {
|
||||
@@ -393,7 +400,8 @@ export default function StoreDetailView({ store, onBack, canManage = true }: Sto
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Immersive Analytics Banner (With Store Cover Image & Slate Gradient Overlay) ── */}
|
||||
{/* ── Immersive Analytics Banner — hidden on the standalone Inventory & Customers pages ── */}
|
||||
{showHero && (
|
||||
<div className="relative overflow-hidden rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300">
|
||||
{/* Cover Image Background */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
@@ -488,8 +496,10 @@ export default function StoreDetailView({ store, onBack, canManage = true }: Sto
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Visual Glass-look Tab Controls ── */}
|
||||
{/* ── Visual Glass-look Tab Controls (full tabbed console only) ── */}
|
||||
{!only && (
|
||||
<div className="flex gap-2 border-b border-[#e2e8f0] pb-[1px] select-none overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
@@ -527,21 +537,11 @@ export default function StoreDetailView({ store, onBack, canManage = true }: Sto
|
||||
<Users size={14} />
|
||||
<span>Customer CRM Base ({customersList.length})</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('orders')}
|
||||
className={`flex items-center gap-xs px-md pb-sm text-xs font-bold uppercase tracking-wider cursor-pointer border-b-2 transition-all ${
|
||||
activeTab === 'orders'
|
||||
? 'border-b-[#581c87] text-[#581c87]'
|
||||
: 'border-b-transparent text-zinc-400 hover:text-zinc-600'
|
||||
}`}
|
||||
>
|
||||
<ShoppingBag size={14} />
|
||||
<span>Orders & Deliveries</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── TAB PAYLOAD AREA ── */}
|
||||
{activeTab === 'overview' && (
|
||||
{section === 'overview' && (
|
||||
<div className="space-y-lg animate-in fade-in duration-300">
|
||||
|
||||
{/* Top Metric Cards */}
|
||||
@@ -721,12 +721,12 @@ export default function StoreDetailView({ store, onBack, canManage = true }: Sto
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'inventory' && (
|
||||
{section === 'inventory' && (
|
||||
<div className="space-y-lg animate-in fade-in duration-300">
|
||||
|
||||
{/* Inventory search, metrics & catalogue tools */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-md bg-white border border-[#eceef2] p-md rounded-2xl shadow-sm">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<div className="relative w-full md:w-80 md:shrink-0">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-450" />
|
||||
<input
|
||||
type="text"
|
||||
@@ -885,12 +885,12 @@ export default function StoreDetailView({ store, onBack, canManage = true }: Sto
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'customers' && (
|
||||
{section === 'customers' && (
|
||||
<div className="space-y-lg animate-in fade-in duration-300">
|
||||
|
||||
{/* Customer directory search and metrics */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-md bg-white border border-[#eceef2] p-md rounded-2xl shadow-sm">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<div className="relative w-full sm:w-80 sm:shrink-0">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
type="text"
|
||||
@@ -989,13 +989,7 @@ export default function StoreDetailView({ store, onBack, canManage = true }: Sto
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'orders' && (
|
||||
<OrdersDeliveriesView
|
||||
searchQuery=""
|
||||
isCoimbatoreView={false}
|
||||
locationid={locationid}
|
||||
/>
|
||||
)}
|
||||
{/* Orders & Deliveries moved out of the store console into their own pages. */}
|
||||
|
||||
{/* ── Replenishment Modal Dialog Overlay ── */}
|
||||
{replenishModal.show && replenishModal.item && (
|
||||
|
||||
@@ -4,7 +4,21 @@
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { AlertTriangle, LayoutDashboard, User, Mail, Phone, Store, ShieldCheck } from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
LayoutDashboard,
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
Store,
|
||||
ShieldCheck,
|
||||
ShoppingBag,
|
||||
Truck,
|
||||
Route,
|
||||
ClipboardList,
|
||||
Layers,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useFiestaTenantLocations,
|
||||
useFiestaLocationSummary,
|
||||
@@ -14,6 +28,11 @@ import { str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||
import type { AuthUser } from '../services/auth';
|
||||
import Header from './Header';
|
||||
import StoreDetailView from './StoreDetailView';
|
||||
import StoreCatalogView from './StoreCatalogView';
|
||||
import OrdersView from './OrdersView';
|
||||
import DeliveriesView from './DeliveriesView';
|
||||
import DispatchView from './DispatchView';
|
||||
import DeliveryReportsView from './DeliveryReportsView';
|
||||
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
||||
|
||||
interface UserStorePageProps {
|
||||
@@ -27,6 +46,12 @@ interface UserStorePageProps {
|
||||
// gets a matching branch in `renderSection` below.
|
||||
const NAV_ITEMS: UserNavItem[] = [
|
||||
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
||||
{ id: 'inventory', label: 'Inventory & Catalog', icon: Layers },
|
||||
{ id: 'customers', label: 'Customers', icon: Users },
|
||||
{ id: 'orders', label: 'Orders', icon: ShoppingBag },
|
||||
{ id: 'deliveries', label: 'Deliveries', icon: Truck },
|
||||
{ id: 'dispatch', label: 'Dispatch', icon: Route },
|
||||
{ id: 'reports', label: 'Delivery Reports', icon: ClipboardList },
|
||||
{ id: 'account', label: 'My Account', icon: User },
|
||||
];
|
||||
|
||||
@@ -161,6 +186,17 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
const renderSection = () => {
|
||||
if (activeSection === 'account') return renderAccount();
|
||||
|
||||
// Logistics console — scoped to this user's store. These views own their
|
||||
// loading/error states, so they don't need the store-console load gating below.
|
||||
if (activeSection === 'orders') return <OrdersView locationid={resolvedLocationId || undefined} />;
|
||||
if (activeSection === 'deliveries') return <DeliveriesView locationid={resolvedLocationId || undefined} />;
|
||||
if (activeSection === 'dispatch') return <DispatchView locationid={resolvedLocationId || undefined} />;
|
||||
if (activeSection === 'reports') return <DeliveryReportsView />;
|
||||
// Inventory & Catalog is its own page: the manager-curated catalog the user
|
||||
// stocks from (the catalog query is tenant-level, so it doesn't need the store
|
||||
// gating below — only "My Store Inventory" uses the resolved location id).
|
||||
if (activeSection === 'inventory') return <StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} />;
|
||||
|
||||
// The store console needs a resolved store, so gate it on the load state.
|
||||
if (locationsQ.isLoading || locSummaryQ.isLoading) {
|
||||
return (
|
||||
@@ -218,7 +254,12 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
// canManage=false hides write actions in the console. NOTE: this is a UI-only
|
||||
// restriction — the backend must also enforce role-based authorization on the
|
||||
// write endpoints, since a hidden button doesn't stop a direct API call.
|
||||
return <StoreDetailView store={buildStore()} canManage={false} />;
|
||||
//
|
||||
// The store console is split into separate pages: the "console" nav shows only
|
||||
// Overview & Performance; Customers is its own page (Inventory & Catalog is the
|
||||
// dedicated StoreCatalogView, handled above).
|
||||
const only = activeSection === 'customers' ? 'customers' : 'overview';
|
||||
return <StoreDetailView store={buildStore()} canManage={false} only={only} />;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -240,13 +281,19 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
/>
|
||||
|
||||
<main
|
||||
className={`flex-1 min-w-0 min-h-[calc(100vh-80px)] transition-all duration-300 ${
|
||||
sidebarOpen ? 'md:pl-64' : 'md:pl-20'
|
||||
}`}
|
||||
className={`flex-1 min-w-0 transition-all duration-300 ${
|
||||
// Dispatch is a full-bleed cockpit — fill the area exactly (no page
|
||||
// padding) so it sits flush under the header. Other pages stay padded.
|
||||
activeSection === 'dispatch' ? 'h-[calc(100vh-80px)] overflow-hidden' : 'min-h-[calc(100vh-80px)]'
|
||||
} ${sidebarOpen ? 'md:pl-64' : 'md:pl-20'}`}
|
||||
>
|
||||
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
||||
{renderSection()}
|
||||
</div>
|
||||
{activeSection === 'dispatch' ? (
|
||||
<div className="w-full h-full">{renderSection()}</div>
|
||||
) : (
|
||||
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
||||
{renderSection()}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
304
src/components/consoleUi.tsx
Normal file
304
src/components/consoleUi.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Console UI kit — the shared visual language ported from the operations console
|
||||
* (nearle_console). Orders / Deliveries / Delivery-Reports all render against this
|
||||
* so they match the source design exactly: brand purple #662582, the tint/soft/
|
||||
* ring/edge alpha scale, pill filters + tabs, KPI cards with a gradient top-bar,
|
||||
* status chips, metric pills, stamp cells, gradient headers and total bars.
|
||||
*
|
||||
* Per the design's own model, accent colours are data-driven (per status / per
|
||||
* card), so colour-bearing bits use inline styles (the natural translation of the
|
||||
* source's MUI `sx`) while layout/spacing use Tailwind.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
// ── Design tokens ────────────────────────────────────────────────────────────────
|
||||
export const BRAND = '#662582';
|
||||
export const BRAND_LIGHT = '#9255AB';
|
||||
export const TEXT = '#0f172a';
|
||||
export const TEXT_2 = '#64748b';
|
||||
export const TEXT_3 = '#94a3b8';
|
||||
export const BORDER = '#e2e8f0';
|
||||
export const DIVIDER = '#f1f5f9';
|
||||
export const SURFACE_ALT = '#f8fafc';
|
||||
export const SHADOW_MD = '0 8px 24px rgba(15, 23, 42, 0.08)';
|
||||
export const SHADOW_SOFT = '0 14px 40px rgba(15, 23, 42, 0.10)';
|
||||
export const SHADOW_POP = '0 18px 50px rgba(15, 23, 42, 0.18)';
|
||||
|
||||
/** Alpha helpers — append #RRGGBBAA suffixes (08≈3%, 18≈9%, 26≈15%, 55≈33%). */
|
||||
export const tint = (c: string) => `${c}08`;
|
||||
export const soft = (c: string) => `${c}18`;
|
||||
export const ring = (c: string) => `${c}26`;
|
||||
export const edge = (c: string) => `${c}55`;
|
||||
|
||||
// ── Status colour maps ───────────────────────────────────────────────────────────
|
||||
/** Order lifecycle (orders board). */
|
||||
export const ORDER_STATUS: Record<string, string> = {
|
||||
created: '#0ea5e9',
|
||||
pending: '#f59e0b',
|
||||
processing: '#0ea5e9',
|
||||
modified: '#06b6d4',
|
||||
confirmed: '#10b981',
|
||||
accepted: '#6366f1',
|
||||
ready: '#6366f1',
|
||||
delivered: '#10b981',
|
||||
cancelled: '#ef4444',
|
||||
};
|
||||
/** Delivery lifecycle (STATUS_META). */
|
||||
export const DELIVERY_STATUS: Record<string, string> = {
|
||||
pending: '#f59e0b',
|
||||
accepted: '#6366f1',
|
||||
arrived: '#06b6d4',
|
||||
picked: '#8b5cf6',
|
||||
active: '#14b8a6',
|
||||
skipped: '#f97316',
|
||||
delivered: '#10b981',
|
||||
cancelled: '#ef4444',
|
||||
};
|
||||
export const statusColor = (map: Record<string, string>, s: string) => map[s.toLowerCase()] || TEXT_2;
|
||||
|
||||
// ── Gradient header ──────────────────────────────────────────────────────────────
|
||||
export function GradientHeader({
|
||||
title,
|
||||
subtitle,
|
||||
status,
|
||||
right,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
status?: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-4 sm:p-5 mb-4"
|
||||
style={{ borderColor: BORDER, background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`, boxShadow: SHADOW_MD }}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
className="hidden sm:flex shrink-0 items-center justify-center rounded-2xl text-white"
|
||||
style={{ width: 46, height: 46, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
|
||||
>
|
||||
<BrandMark />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-extrabold tracking-tight leading-tight text-[1.4rem] md:text-[1.75rem]" style={{ color: TEXT }}>
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="text-xs mt-0.5" style={{ color: TEXT_2 }}>{subtitle}</p>}
|
||||
{status && <div className="mt-1">{status}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{right && <div className="shrink-0">{right}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandMark() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 7h13l5 5-5 5H3z" />
|
||||
<circle cx="8" cy="12" r="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Live / loading / error status line used under the header title. */
|
||||
export function LiveStatus({ state, label }: { state: 'live' | 'loading' | 'error'; label: string }) {
|
||||
const color = state === 'error' ? '#ef4444' : state === 'loading' ? '#94a3b8' : '#10b981';
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold" style={{ color: TEXT_2 }}>
|
||||
<span
|
||||
className={`rounded-full ${state === 'loading' ? 'animate-pulse' : ''}`}
|
||||
style={{ width: 8, height: 8, background: color, boxShadow: state === 'live' ? `0 0 0 4px ${color}2e` : undefined }}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── KPI cards ────────────────────────────────────────────────────────────────────
|
||||
export interface KpiItem {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
badge?: string;
|
||||
}
|
||||
export function KpiStrip({ items, loading }: { items: KpiItem[]; loading?: boolean }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4">
|
||||
{items.map((it) => (
|
||||
<div
|
||||
key={it.label}
|
||||
className="relative overflow-hidden rounded-2xl border bg-white p-3.5 sm:p-5 transition-all duration-200 hover:-translate-y-0.5"
|
||||
style={{ borderColor: BORDER }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = SHADOW_MD)}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
|
||||
>
|
||||
<div className="absolute top-0 left-0 right-0" style={{ height: 3, background: `linear-gradient(90deg, ${it.color} 0%, ${soft(it.color)} 100%)` }} />
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] sm:text-[11px] font-bold uppercase tracking-wide truncate" style={{ color: TEXT_2, letterSpacing: 0.4 }}>
|
||||
{it.label}
|
||||
</p>
|
||||
<p className="font-extrabold leading-none mt-1.5 text-[1.4rem] sm:text-[1.6rem]" style={{ color: TEXT }}>
|
||||
{loading ? '—' : it.value}
|
||||
</p>
|
||||
{it.badge && (
|
||||
<span className="inline-flex items-center mt-1.5 rounded-full font-extrabold" style={{ padding: '1px 8px', fontSize: 10.5, background: soft(it.color), color: it.color }}>
|
||||
{it.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="shrink-0 rounded-full flex items-center justify-center"
|
||||
style={{ width: 46, height: 46, background: soft(it.color), color: it.color, boxShadow: `inset 0 0 0 1px ${edge(it.color)}` }}
|
||||
>
|
||||
{it.icon}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pill (filter chip / tab) ─────────────────────────────────────────────────────
|
||||
interface PillProps {
|
||||
active: boolean;
|
||||
color: string;
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
count?: number | string;
|
||||
}
|
||||
export function Pill({ active, color, onClick, title, children, count }: PillProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className="inline-flex items-center gap-1.5 rounded-full font-bold whitespace-nowrap transition-all duration-150 cursor-pointer shrink-0"
|
||||
style={
|
||||
active
|
||||
? { padding: '5px 12px', fontSize: 12.5, background: color, color: '#fff', border: `1.5px solid ${color}`, boxShadow: `0 6px 18px ${ring(color)}` }
|
||||
: { padding: '5px 12px', fontSize: 12.5, background: tint(color), color, border: `1.5px solid ${edge(color)}` }
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{count != null && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-full font-extrabold"
|
||||
style={
|
||||
active
|
||||
? { minWidth: 22, height: 18, padding: '0 6px', fontSize: 10.5, background: 'rgba(255,255,255,0.22)', color: '#fff' }
|
||||
: { minWidth: 22, height: 18, padding: '0 6px', fontSize: 10.5, background: '#fff', color, border: `1px solid ${edge(color)}` }
|
||||
}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Status chip (table cell) ─────────────────────────────────────────────────────
|
||||
export function StatusChip({ label, color }: { label: string; color: string }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full font-extrabold uppercase whitespace-nowrap"
|
||||
style={{ padding: '3px 9px', fontSize: 10.5, background: tint(color), border: `1px solid ${edge(color)}`, color, letterSpacing: 0.3 }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Metric pill (km / amount / count cells) ──────────────────────────────────────
|
||||
export function MetricPill({ color, children, minWidth }: { color: string; children: React.ReactNode; minWidth?: number }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center gap-1 rounded-full font-extrabold whitespace-nowrap"
|
||||
style={{ padding: '2px 9px', fontSize: 11, background: tint(color), border: `1px solid ${edge(color)}`, color, minWidth }}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stamp cell (date over time) ──────────────────────────────────────────────────
|
||||
export function StampCell({ date, time }: { date?: string; time?: string }) {
|
||||
if (!date && !time) return <span style={{ color: TEXT_3 }}>—</span>;
|
||||
return (
|
||||
<div className="leading-tight">
|
||||
{date && <div className="text-[11px] font-semibold whitespace-nowrap" style={{ color: TEXT_2 }}>{date}</div>}
|
||||
{time && <div className="text-[11px] font-extrabold whitespace-nowrap" style={{ color: TEXT }}>{time}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Search pill ──────────────────────────────────────────────────────────────────
|
||||
export function SearchPill({ value, onChange, placeholder, color = BRAND }: { value: string; onChange: (v: string) => void; placeholder?: string; color?: string }) {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="block w-full rounded-full outline-none font-medium transition-all box-border"
|
||||
style={{ height: 38, paddingLeft: 32, paddingRight: value ? 30 : 14, fontSize: 12.5, background: tint(color), border: `1.5px solid ${edge(color)}`, color: TEXT }}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = color; e.currentTarget.style.boxShadow = `0 0 0 3px ${ring(color)}`; }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = edge(color); e.currentTarget.style.boxShadow = 'none'; }}
|
||||
/>
|
||||
{value && (
|
||||
<button onClick={() => onChange('')} className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer" style={{ color: TEXT_3 }} title="Clear">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Card shells & table head cell ────────────────────────────────────────────────
|
||||
export function Card({ children, className = '', flush }: { children: React.ReactNode; className?: string; flush?: 'top' }) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-white border ${flush === 'top' ? 'rounded-b-2xl' : 'rounded-2xl'} ${className}`}
|
||||
style={{ borderColor: BORDER }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Filter/tab bar paper that visually joins the table below it (flat bottom). */
|
||||
export function FilterBar({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`bg-white border rounded-2xl p-3 sm:p-4 ${className}`} style={{ borderColor: BORDER, boxShadow: SHADOW_MD }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TH_STYLE: React.CSSProperties = {
|
||||
background: SURFACE_ALT,
|
||||
color: TEXT_2,
|
||||
fontSize: 10.5,
|
||||
fontWeight: 800,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: 'uppercase',
|
||||
whiteSpace: 'nowrap',
|
||||
borderBottom: `1px solid ${BORDER}`,
|
||||
};
|
||||
Reference in New Issue
Block a user