200 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|