Files
daily_merchant_web/src/components/DeliveriesView.tsx

324 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* 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>
);
}