Files
daily_merchant_web/src/components/DispatchHubView.tsx
2026-08-03 15:36:34 +05:30

200 lines
12 KiB
TypeScript

/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import { Route, ShoppingBag, Truck, MapPin, Calendar, ChevronLeft, ChevronRight, Store, Users } from 'lucide-react';
import DispatchView from './DispatchView';
import OrdersView from './OrdersView';
import DeliveriesView from './DeliveriesView';
import { useFiestaDeliveries, useFiestaTenantLocations } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, ymd, num as fnum, str as fstr } from '../services/fiestaApi';
import type { Row } from '../services/fiestaApi';
import './DispatchView.css'; // For #hdr and date-chip styles
interface DispatchHubViewProps {
locationid?: number;
tenantId?: 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 DispatchHubView({ locationid, tenantId = FIESTA_TENANT_ID }: DispatchHubViewProps) {
const [activeTab, setActiveTab] = useState<'map' | 'orders' | 'deliveries'>('map');
const [mapViewMode, setMapViewMode] = useState<'stores' | 'customers'>('stores');
const today = new Date();
const [date, setDate] = useState<string>(ymd(today));
// Fetch today's deliveries just for the global Hub header live stats
const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid });
const allRows = deliveriesQ.data ?? [];
const inScope = (r: Row) => !locationid || fnum(r.locationid) === locationid;
const rows = useMemo(() => allRows.filter(inScope), [allRows, locationid]);
const totalOrders = rows.length;
// Operating area for the header pill. This was the hardcoded string
// "Coimbatore", shown to every tenant on the platform regardless of where they
// actually trade. The locations query is already cached by DispatchView (same
// key), so reading it here costs no extra request.
const locationsQ = useFiestaTenantLocations(tenantId);
const scopeLabel = useMemo(() => {
const locs = locationsQ.data ?? [];
if (locs.length === 0) return '';
if (locationid) {
// Store user — name the outlet's own city.
const mine = locs.find((l) => fnum(l.locationid) === locationid);
return fstr(mine?.city).trim() || fstr(mine?.state).trim();
}
// Admin — one city if the whole tenant sits in one, otherwise a count.
const cities = [...new Set(locs.map((l) => fstr(l.city).trim()).filter(Boolean))];
if (cities.length === 1) return cities[0];
if (cities.length > 1) return `${cities.length} cities`;
return '';
}, [locationsQ.data, locationid]);
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));
};
return (
<div className="flex flex-col min-h-[calc(100vh-80px)] md:h-[calc(100vh-80px)] w-full">
<div className="flex flex-col flex-none bg-white">
{/* ── Unified Hub Header ── */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-3 sm:px-6 py-2 sm:py-0 sm:h-14 gap-2 border-b border-slate-200">
<div className="flex items-center gap-3 sm:gap-5">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center font-extrabold text-xs sm:text-sm text-white shadow-sm">C</div>
<div className="text-base sm:text-lg font-extrabold text-slate-800 tracking-tight">Console</div>
{scopeLabel && (
<div className="relative inline-block ml-1">
<span className="flex items-center gap-1 px-2 sm:px-2.5 py-0.5 sm:py-1 bg-purple-50 text-purple-700 border border-purple-200/60 rounded-full text-[10px] sm:text-[11px] font-bold cursor-default shadow-sm">
<MapPin size={12} className="text-purple-500" />
<span className="max-w-[120px] sm:max-w-[180px] truncate">{scopeLabel}</span>
</span>
</div>
)}
</div>
</div>
<div className="flex items-center justify-between sm:justify-end gap-2 sm:gap-4">
{deliveriesQ.isLoading ? (
<span className="flex items-center gap-2 text-xs font-semibold text-slate-500">
<span className="w-2 h-2 rounded-full bg-slate-300 animate-pulse" /> Syncing
</span>
) : deliveriesQ.isError ? (
<span className="flex items-center gap-2 text-xs font-semibold text-rose-500">
<span className="w-2 h-2 rounded-full bg-rose-500" /> Offline
</span>
) : totalOrders === 0 ? (
<span className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-semibold text-slate-500 bg-slate-100 px-2.5 sm:px-3 py-1 sm:py-1.5 rounded-full" title="No deliveries dispatched for this day">
<span className="w-2 h-2 rounded-full bg-slate-400" /> No deliveries today
</span>
) : (
<span className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-semibold text-emerald-600 bg-emerald-50 px-2.5 sm:px-3 py-1 sm:py-1.5 rounded-full border border-emerald-100">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" /> Live · {totalOrders} {totalOrders === 1 ? 'delivery' : 'deliveries'}
</span>
)}
<div className={`flex items-center bg-slate-50 border border-slate-200 rounded-xl p-0.5 sm:p-1 shadow-sm ${isToday ? 'ring-1 ring-blue-500/20' : ''}`}>
<button className="p-1 sm:p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-200/50 transition-colors" onClick={() => shiftDate(-1)} title="Previous day">
<ChevronLeft size={16} />
</button>
<div className="relative flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-0.5 sm:py-1">
<Calendar size={13} className="text-slate-400 hidden sm:block" />
<div className="flex flex-col">
<span className="text-[8px] sm:text-[9px] font-bold text-slate-400 uppercase tracking-widest leading-none mb-0.5 flex items-center gap-1">
Date {isToday && <span className="text-[7px] sm:text-[8px] bg-blue-100 text-blue-600 px-1 rounded-sm">TODAY</span>}
</span>
<span className="text-[11px] sm:text-xs font-bold text-slate-700 leading-none">{prettyDate}</span>
</div>
<input
type="date"
value={date}
max={ymd(today)}
onChange={(e) => setDate(e.target.value)}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
aria-label="Pick date"
/>
</div>
<button className="p-1 sm:p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-200/50 transition-colors disabled:opacity-30 disabled:hover:bg-transparent" onClick={() => shiftDate(1)} disabled={isToday} title="Next day">
<ChevronRight size={16} />
</button>
</div>
</div>
</div>
{/* ── Unified Tab Row ── */}
<div className="flex items-center px-3 sm:px-6 py-2.5 border-b border-slate-200 shadow-sm z-10 relative overflow-x-auto custom-scrollbar no-scrollbar touch-scrolling">
<div className="flex items-center p-1 bg-slate-100/80 rounded-lg border border-slate-200/80 shadow-inner shrink-0">
<button
onClick={() => setActiveTab('map')}
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-[11px] font-extrabold rounded-md transition-all duration-300 uppercase tracking-wide ${
activeTab === 'map' ? 'bg-white text-purple-700 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
}`}
>
<Route size={13} className={activeTab === 'map' ? 'text-purple-600' : 'text-slate-400'} /> Map
</button>
<button
onClick={() => setActiveTab('orders')}
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-[11px] font-extrabold rounded-md transition-all duration-300 uppercase tracking-wide ${
activeTab === 'orders' ? 'bg-white text-purple-700 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
}`}
>
<ShoppingBag size={13} className={activeTab === 'orders' ? 'text-purple-600' : 'text-slate-400'} /> Orders
</button>
<button
onClick={() => setActiveTab('deliveries')}
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-[11px] font-extrabold rounded-md transition-all duration-300 uppercase tracking-wide ${
activeTab === 'deliveries' ? 'bg-white text-purple-700 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
}`}
>
<Truck size={13} className={activeTab === 'deliveries' ? 'text-purple-600' : 'text-slate-400'} /> Deliveries
</button>
</div>
{/* Sub-tabs for Map View — only meaningful on the Map tab; they used to
stay visible (and inert) on Orders and Deliveries. */}
{activeTab === 'map' && (
<div className="flex items-center p-1 ml-3 sm:ml-4 bg-slate-100/80 rounded-lg border border-slate-200/80 shadow-inner shrink-0">
<button
onClick={() => setMapViewMode('stores')}
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-[11px] font-extrabold rounded-md transition-all duration-300 tracking-wide ${
mapViewMode === 'stores' ? 'bg-white text-slate-800 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
}`}
>
<Store size={13} className={mapViewMode === 'stores' ? 'text-blue-500' : 'text-slate-400'} /> By Store
</button>
<button
onClick={() => setMapViewMode('customers')}
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-[11px] font-extrabold rounded-md transition-all duration-300 tracking-wide ${
mapViewMode === 'customers' ? 'bg-white text-slate-800 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
}`}
>
<Users size={13} className={mapViewMode === 'customers' ? 'text-blue-500' : 'text-slate-400'} /> By Customer
</button>
</div>
)}
</div>
</div>
{/* Content Area */}
<div className={`flex-1 min-h-0 bg-[#f8fafc] ${activeTab !== 'map' ? 'overflow-y-auto w-full p-4 md:p-8 relative' : 'relative'}`}>
{activeTab === 'map' && <DispatchView locationid={locationid} tenantId={tenantId} date={date} viewMode={mapViewMode} />}
{activeTab === 'orders' && <OrdersView locationid={locationid} tenantId={tenantId} date={date} />}
{activeTab === 'deliveries' && <DeliveriesView locationid={locationid} tenantId={tenantId} date={date} />}
</div>
</div>
);
}