Files
daily_merchant_web/src/components/OrdersDeliveriesView.tsx

505 lines
25 KiB
TypeScript

/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import {
ShoppingBag,
Truck,
CheckCircle2,
Clock,
UserCheck,
MapPin,
TrendingUp,
ChevronRight,
Package,
ArrowRight,
AlertCircle,
Clock4,
Search,
Check,
Calendar,
X
} from 'lucide-react';
import { CustomerOrder } from '../types';
import {
useFiestaDeliveries,
useFiestaDeliverySummary,
useFiestaRiders,
useFiestaOrderDetails,
} from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
import { deliveryRowToOrder } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi';
interface OrdersDeliveriesViewProps {
searchQuery?: string;
isCoimbatoreView?: boolean;
locationid?: number;
}
interface DeliveryExecutive {
id: string;
name: string;
phone: string;
status: 'Active Duty' | 'Idle' | 'Offline';
completedToday: number;
currentZone: string;
avatar: string;
}
const RIDER_AVATARS = [
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=150&q=80',
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=150&q=80',
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=150&q=80',
];
function riderRowToExecutive(row: Record<string, unknown>, idx: number): DeliveryExecutive {
return {
id: `DE-${fstr(row.userid) || idx}`,
name: fstr(row.fullname) || `${fstr(row.firstname)} ${fstr(row.lastname)}`.trim() || 'Rider',
phone: fstr(row.contactno) || '—',
status: fstr(row.starttime) ? 'Active Duty' : 'Idle',
completedToday: fnum(row.completed) || fnum(row.deliverycount),
currentZone: fstr(row.city) || fstr(row.vehiclename) || fstr(row.vehicleno) || 'Coimbatore',
avatar: RIDER_AVATARS[idx % RIDER_AVATARS.length],
};
}
export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreView = false, locationid }: OrdersDeliveriesViewProps) {
// ── Live deliveries / fleet (Fiesta) ──────────────────────────────────────
// Order feed + dispatch controls run off the live deliveries board; the KPI
// strip uses the delivery summary; the fleet panel uses the active riders.
// A date-range filter lets the user view orders/deliveries day-wise.
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));
// Quick-range presets (computed off the current day; no Date.now in render path).
const dayOffset = (n: number) => {
const d = new Date();
d.setDate(d.getDate() - n);
return ymd(d);
};
const presets: Array<{ key: string; label: string; from: string; to: string }> = [
{ 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 deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
const summaryQ = useFiestaDeliverySummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
const [orders, setOrders] = useState<CustomerOrder[]>([]);
const [executives, setExecutives] = useState<DeliveryExecutive[]>([]);
const [selectedOrder, setSelectedOrder] = useState<CustomerOrder | null>(null);
const [filterStatus, setFilterStatus] = useState<string>('ALL');
const [localSearch, setLocalSearch] = useState('');
// Seed local state once live data arrives so existing dispatch/create handlers
// continue to mutate in-session.
useEffect(() => {
if (deliveriesQ.data) {
const mapped = deliveriesQ.data.map(deliveryRowToOrder);
setOrders(mapped);
// Keep the current selection only if it's still in the new range; otherwise
// fall back to the first order so the detail panel stays in sync.
setSelectedOrder((prev) =>
(prev && mapped.some((o) => o.id === prev.id)) ? prev : mapped[0] ?? null,
);
}
}, [deliveriesQ.data]);
useEffect(() => {
if (ridersQ.data) setExecutives(ridersQ.data.map(riderRowToExecutive));
}, [ridersQ.data]);
const summary = summaryQ.data;
// Local filtered list of orders
const storeOrders = locationid ? orders.filter(o => o.locationid === locationid) : orders;
const filteredOrdersList = storeOrders.filter(o => {
const term = (localSearch || searchQuery).toLowerCase();
const matchesSearch = o.id.toLowerCase().includes(term) ||
o.customerName.toLowerCase().includes(term) ||
o.address.toLowerCase().includes(term);
const matchesFilter = filterStatus === 'ALL' || o.status === filterStatus;
return matchesSearch && matchesFilter;
});
// Calculate dynamic stats for metrics cards based on filtered storeOrders
const totalDeliveriesCount = storeOrders.length;
const pendingFulfillmentCount = storeOrders.filter(o => o.status === 'PROCESSING' || o.status === 'CONFIRMED').length;
const activeDispatchCount = storeOrders.filter(o => o.status === 'OUT_FOR_DELIVERY').length;
const completedDeliveriesCount = storeOrders.filter(o => o.status === 'DELIVERED').length;
// Live line-item details for the currently selected order. The deliveries board
// only carries an itemCount; the actual basket lines come from this endpoint.
const orderDetailsQ = useFiestaOrderDetails(selectedOrder?.id ?? null);
const orderItems = (orderDetailsQ.data ?? []).map((row) => {
const quantity = fnum(row.quantity) || fnum(row.qty);
const price = fnum(row.price) || fnum(row.unitprice);
const lineTotal = fnum(row.amount) || price * quantity;
return {
name: fstr(row.productname) || fstr(row.itemname) || 'Item',
quantity,
price,
lineTotal,
};
});
return (
<div className="space-y-lg animate-in fade-in duration-500">
{/* View Header with Statistics Overview */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-md border-b border-[#e2e8f0] pb-xl">
<div>
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
Orders & Delivery Operations
</h1>
<p className="text-zinc-500 font-sans text-xs mt-1">
Real-time tracking of app orders, dispatch queues, and active delivery partners across Coimbatore regional sub-hubs.
</p>
<div className="mt-1.5">
{deliveriesQ.isLoading ? (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading live deliveries
</span>
) : deliveriesQ.isError ? (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Live data unavailable
</span>
) : (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {orders.length} deliveries · {executives.length} riders
</span>
)}
</div>
</div>
</div>
{/* Top Level Delivery Performance Indicators */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-gutter font-sans">
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
<div className="p-2 bg-purple-50 text-[#581c87] rounded-lg">
<ShoppingBag size={20} />
</div>
<div>
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Deliveries in Range</p>
<p className="font-sans font-bold text-lg text-zinc-800">{totalDeliveriesCount.toLocaleString('en-IN')} total</p>
<p className="text-[10px] text-emerald-600 font-semibold mt-0.5">{fromdate === todate ? fromdate : `${fromdate}${todate}`}</p>
</div>
</div>
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
<div className="p-2 bg-amber-50 text-amber-600 rounded-lg">
<Clock size={20} />
</div>
<div>
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Pending Fulfilment</p>
<p className="font-sans font-bold text-lg text-zinc-800">
{pendingFulfillmentCount + activeDispatchCount} active
</p>
<p className="text-[10px] text-amber-600 font-semibold mt-0.5">Awaiting dispatch / in transit</p>
</div>
</div>
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
<div className="p-2 bg-emerald-50 text-emerald-600 rounded-lg">
<Truck size={20} />
</div>
<div>
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Successful Deliveries</p>
<p className="font-sans font-bold text-lg text-zinc-800">
{completedDeliveriesCount} done
</p>
<p className="text-[10px] text-[#581c87] font-semibold mt-0.5">{locationid ? 'At this location' : 'Across all locations'}</p>
</div>
</div>
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex items-center gap-md shadow-sm">
<div className="p-2 bg-purple-50 text-purple-600 rounded-lg">
<UserCheck size={20} />
</div>
<div>
<p className="text-[10px] text-zinc-400 uppercase tracking-wider font-bold">Active Delivery Fleet</p>
<p className="font-sans font-bold text-lg text-zinc-800">
{executives.filter(e => e.status !== 'Offline').length} partners
</p>
<p className="text-[10px] text-purple-600 font-semibold mt-0.5">{executives.length} riders registered</p>
</div>
</div>
</div>
{/* Day-wise date filter — drives the live deliveries + summary queries */}
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm flex flex-col lg:flex-row lg:items-center justify-between gap-md">
<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 pr-1">
<Calendar size={13} className="text-[#581c87]" /> View
</span>
{presets.map((p) => (
<button
key={p.key}
onClick={() => { setFromdate(p.from); setTodate(p.to); }}
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border cursor-pointer ${
activePreset === p.key
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
: 'bg-white text-zinc-600 border-[#e2e8f0] hover:bg-zinc-50'
}`}
>
{p.label}
</button>
))}
</div>
<div className="flex items-center gap-sm text-xs">
<div className="flex items-center gap-1.5">
<label className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider">From</label>
<input
type="date"
value={fromdate}
max={todate}
onChange={(e) => setFromdate(e.target.value)}
className="border border-[#e2e8f0] rounded-lg px-2 py-1.5 text-zinc-700 font-medium outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
/>
</div>
<span className="text-zinc-300"></span>
<div className="flex items-center gap-1.5">
<label className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider">To</label>
<input
type="date"
value={todate}
min={fromdate}
max={ymd(today)}
onChange={(e) => setTodate(e.target.value)}
className="border border-[#e2e8f0] rounded-lg px-2 py-1.5 text-zinc-700 font-medium outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
/>
</div>
</div>
</div>
{/* Main interactive segment splits */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter text-xs font-sans">
{/* Left List of Customer App Orders */}
<div className="lg:col-span-2 flex">
<div className="bg-white border border-[#e2e8f0] rounded-xl shadow-sm overflow-hidden flex flex-col h-full w-full min-h-[32rem]">
<div className="flex flex-col flex-1 min-h-0">
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex flex-col gap-md shrink-0">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-sm">
<div>
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
Customer Orders Feed ({filteredOrdersList.length})
</h4>
<p className="text-[10px] text-zinc-400 font-medium mt-0.5">Interactive list of customer purchases made via client app</p>
</div>
</div>
<div className="flex flex-col sm:flex-row items-center gap-sm w-full">
{/* Local Search Input */}
<div className="relative w-full sm:max-w-xs">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
type="text"
placeholder="Search orders by customer, street, ID..."
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
className="w-full pl-8 pr-4 py-1.5 border border-[#e2e8f0] rounded-lg text-[11px] outline-none bg-white focus:ring-1 focus:ring-[#581c87] transition-all"
/>
</div>
{/* Filter Status buttons */}
<div className="flex gap-1 overflow-x-auto w-full sm:w-auto">
{['ALL', 'PROCESSING', 'CONFIRMED', 'OUT_FOR_DELIVERY', 'DELIVERED'].map((st) => (
<button
key={st}
onClick={() => setFilterStatus(st)}
className={`px-2 py-1.5 rounded text-[9px] font-bold uppercase transition-all border outline-none cursor-pointer whitespace-nowrap ${
filterStatus === st
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
: 'bg-white text-zinc-500 border-[#e2e8f0] hover:bg-zinc-50'
}`}
>
{st.replace(/_/g, ' ')}
</button>
))}
</div>
</div>
</div>
{/* Order item rows — flex-fills the column so the feed matches the Order Details card height */}
<div className="divide-y divide-[#f1f5f9] flex-1 min-h-0 overflow-y-auto">
{filteredOrdersList.length === 0 ? (
<div className="p-xl text-center text-zinc-400 font-medium">
No orders matching status filter found. Try another query or adjust the date range.
</div>
) : (
filteredOrdersList.map(order => (
<div
key={order.id}
onClick={() => setSelectedOrder(order)}
className={`p-md flex items-center justify-between hover:bg-zinc-50 border-l-4 transition-all cursor-pointer ${
selectedOrder?.id === order.id ? 'bg-[#faf5ff]/50 border-[#581c87]' : 'border-transparent'
}`}
>
<div className="space-y-1">
<div className="flex items-center gap-sm">
<span className="font-bold text-zinc-700">{order.customerName}</span>
<span className="text-[10px] text-zinc-400"> {order.time}</span>
</div>
<p className="text-zinc-500 truncate max-w-[24rem]">{order.address}</p>
<div className="flex gap-sm py-1 items-center">
<span className="bg-[#f1f5f9] px-1.5 py-0.5 rounded text-[9px] font-bold text-zinc-500 uppercase">{order.hub}</span>
<span className="text-[9px] text-[#581c87] font-bold">{order.itemCount ?? order.items.length} Items</span>
</div>
</div>
<div className="text-right space-y-1">
<p className="font-bold font-mono text-sm text-[#0f172a]">{order.amount.toLocaleString()}</p>
<span className={`px-2 py-0.5 rounded text-[9px] font-bold tracking-wider inline-block uppercase ${
order.status === 'DELIVERED'
? 'bg-emerald-50 text-emerald-600 border border-emerald-100'
: order.status === 'OUT_FOR_DELIVERY'
? 'bg-purple-50 text-purple-700 border border-purple-100'
: order.status === 'CONFIRMED'
? 'bg-amber-50 text-amber-600 border border-amber-100 animate-pulse'
: 'bg-zinc-100 text-zinc-650 border border-zinc-200'
}`}>
{order.status.replace(/_/g, ' ')}
</span>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
{/* Right column — Order Details, shown parallel to the orders feed */}
<div className="lg:col-span-1 space-y-md">
{selectedOrder ? (
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm space-y-md animate-in zoom-in-95 duration-150">
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
Order Details: {selectedOrder.id}
</span>
{/* Customer summary */}
<div className="p-sm bg-[#f8fafc] rounded-lg border border-[#e2e8f0]/50 space-y-xs">
<div className="flex justify-between font-semibold">
<span>Customer Name</span>
<span className="text-zinc-700">{selectedOrder.customerName}</span>
</div>
<div className="flex justify-between font-semibold">
<span>Contact info</span>
<span className="text-zinc-600 font-mono">{selectedOrder.phone}</span>
</div>
<div>
<span className="text-[10px] text-zinc-400 font-bold uppercase block mt-1">Delivery Address</span>
<p className="text-zinc-700 mt-0.5 leading-relaxed font-medium">{selectedOrder.address}</p>
</div>
</div>
{/* Category items description list */}
<div>
<span className="text-[10px] text-zinc-400 font-bold uppercase tracking-wide block mb-sm">Ordered Grocery basket Items:</span>
<div className="divide-y divide-[#f1f5f9] bg-zinc-50/50 p-2.5 rounded-lg border border-[#e2e8f0]/40">
{orderDetailsQ.isLoading && (
<div className="py-2 flex items-center gap-1.5 text-[10px] text-zinc-400 font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading order line items
</div>
)}
{!orderDetailsQ.isLoading && orderItems.length === 0 && (
<div className="py-2 flex justify-between items-center text-xs text-zinc-500">
<span className="font-medium">{selectedOrder.itemCount ?? 0} line item(s)</span>
<span className="text-[10px] text-zinc-400">Detail lines not loaded on board view</span>
</div>
)}
{orderItems.map((item, idx) => (
<div key={idx} className="py-2 flex justify-between items-center text-xs">
<div>
<p className="font-bold text-[#0f172a]">{item.name}</p>
<p className="text-[10px] text-zinc-400">Qty: {item.quantity} x {item.price}</p>
</div>
<span className="font-bold font-mono text-zinc-700">{item.lineTotal}</span>
</div>
))}
<div className="pt-2 flex justify-between items-center font-bold text-sm text-[#581c87] border-t border-dashed border-[#e2e8f0]">
<span>Grand Total Invoice</span>
<span className="font-mono">{selectedOrder.amount.toLocaleString()}</span>
</div>
</div>
</div>
{/* Live GPS route tracker — no rider-telemetry/GPS API yet */}
{selectedOrder.status === 'OUT_FOR_DELIVERY' && (
<div className="space-y-xs pt-xs">
<span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest block">
LIVE GPS ROUTE TRACKER
</span>
<AwaitingApi label="Live rider GPS & ETA" api="[R9]" compact />
</div>
)}
{/* Delivery tracking visual roadmap layout */}
<div className="bg-zinc-50 border border-[#e2e8f0]/60 rounded-xl p-md">
<span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs mb-sm border-b border-[#f1f5f9]">
Live Dispatch Timeline Tracker
</span>
<div className="space-y-xs pt-1 relative text-[11px]">
<div className="flex gap-md items-start relative group">
<span className="text-emerald-500 mt-0.5"><CheckCircle2 size={12} /></span>
<div>
<h5 className="font-semibold text-zinc-800">Order Received ({selectedOrder.time})</h5>
<p className="text-[10px] text-zinc-400">Placed via customer app cart checkout successfully.</p>
</div>
</div>
<div className="flex gap-md items-start pt-3">
<span className={['CONFIRMED', 'OUT_FOR_DELIVERY', 'DELIVERED'].includes(selectedOrder.status) ? 'text-emerald-500 mt-0.5' : 'text-zinc-300 mt-0.5'}><CheckCircle2 size={12} /></span>
<div>
<h5 className="font-semibold text-zinc-800">Assortment Packaged & Bagged</h5>
<p className="text-[10px] text-zinc-400">Verified fresh produce items in-stock levels.</p>
</div>
</div>
<div className="flex gap-md items-start pt-3">
<span className={['OUT_FOR_DELIVERY', 'DELIVERED'].includes(selectedOrder.status) ? 'text-emerald-500 mt-0.5' : 'text-zinc-300 mt-0.5'}><CheckCircle2 size={12} /></span>
<div>
<h5 className="font-semibold text-zinc-800">Out for Delivery</h5>
<p className="text-[10px] text-zinc-400">Dispatched with executive partner on bike route.</p>
</div>
</div>
<div className="flex gap-md items-start pt-3">
<span className={selectedOrder.status === 'DELIVERED' ? 'text-emerald-500 mt-0.5' : 'text-zinc-300 mt-0.5'}><CheckCircle2 size={12} /></span>
<div>
<h5 className="font-semibold text-zinc-800">Handover Verified</h5>
<p className="text-[10px] text-zinc-400">Delivered directly to door step location.</p>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="p-xl bg-white border border-[#e2e8f0] rounded-xl text-center text-zinc-400 font-medium">
Select any customer order from the feed to view its details.
</div>
)}
</div>
</div>
</div>
);
}