updates on the zone selector for the specific login

This commit is contained in:
2026-08-27 12:03:12 +05:30
parent 4be2e73712
commit 656e180675
7 changed files with 425 additions and 61 deletions

View File

@@ -6,6 +6,7 @@ import { queryClientInstance } from '@/lib/query-client';
import { Navigate, BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import { Navigate, BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import PageNotFound from './lib/PageNotFound'; import PageNotFound from './lib/PageNotFound';
import { AuthProvider, useAuth } from '@/lib/AuthContext'; import { AuthProvider, useAuth } from '@/lib/AuthContext';
import { ZoneProvider } from '@/lib/ZoneContext';
import ScrollToTop from './components/ScrollToTop'; import ScrollToTop from './components/ScrollToTop';
import ProtectedRoute from '@/components/ProtectedRoute'; import ProtectedRoute from '@/components/ProtectedRoute';
import AdminLayout from '@/layouts/AdminLayout'; import AdminLayout from '@/layouts/AdminLayout';
@@ -131,15 +132,17 @@ function App() {
return ( return (
<AuthProvider> <AuthProvider>
<QueryClientProvider client={queryClientInstance}> <QueryClientProvider client={queryClientInstance}>
<Router> <ZoneProvider>
<ScrollToTop /> <Router>
<AuthenticatedApp /> <ScrollToTop />
</Router> <AuthenticatedApp />
<Toaster /> </Router>
{/* The design system's `toast` helper wraps react-hot-toast, which needs <Toaster />
its own renderer mounted. Without this every toast in the app fires {/* The design system's `toast` helper wraps react-hot-toast, which needs
into nothing — including the data layer's failure notices. */} its own renderer mounted. Without this every toast in the app fires
<HotToaster position="bottom-right" /> into nothing — including the data layer's failure notices. */}
<HotToaster position="bottom-right" />
</ZoneProvider>
</QueryClientProvider> </QueryClientProvider>
</AuthProvider> </AuthProvider>
); );

View File

@@ -0,0 +1,118 @@
import React, { useMemo, useState } from 'react';
import { Building2, Check, ChevronDown, Globe, MapPin, Search, Warehouse } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useZone } from '@/lib/ZoneContext';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export function ZoneSelector({ className, width = 'w-56 sm:w-64' }) {
const { selectedZone, selectedZoneId, setSelectedZoneId, zones, isAllZones } = useZone();
const [search, setSearch] = useState('');
const filteredZones = useMemo(() => {
if (!search.trim()) return zones;
const q = search.toLowerCase();
return zones.filter(
(z) =>
z.hubname?.toLowerCase().includes(q) ||
z.city?.toLowerCase().includes(q) ||
z.address?.toLowerCase().includes(q) ||
z.pincode?.includes(q)
);
}, [zones, search]);
const displayName = isAllZones ? 'All Zones' : selectedZone.hubname || selectedZone.city || 'Select Zone';
return (
<DropdownMenu onOpenChange={(open) => !open && setSearch('')}>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={`Zone: ${displayName}`}
className={cn(
'flex h-8 items-center gap-1.5 rounded-lg border border-border bg-surface-subtle px-2.5 text-body-sm font-medium text-ink-1 transition-all',
'hover:border-brand/40 hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30 cursor-pointer',
!isAllZones && 'border-brand/30 bg-brand-tint/30 text-brand font-semibold',
className
)}
>
{isAllZones ? (
<Globe className="h-3.5 w-3.5 shrink-0 text-ink-3" aria-hidden="true" />
) : (
<MapPin className="h-3.5 w-3.5 shrink-0 text-brand" aria-hidden="true" />
)}
<span className="max-w-[130px] sm:max-w-[170px] truncate text-xs">{displayName}</span>
<ChevronDown className="h-3 w-3 shrink-0 text-ink-4 opacity-70" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn('p-1', width)}>
<DropdownMenuLabel className="px-2 py-1.5 text-xs font-semibold text-ink-3">
Operational Zone / Hub
</DropdownMenuLabel>
<div className="relative mb-1 px-1">
<Search className="pointer-events-none absolute left-3 top-2.5 h-3.5 w-3.5 text-ink-4" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search hub or city…"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className="h-8 w-full rounded-md border border-border bg-surface-subtle pl-8 pr-2 text-xs text-ink-1 placeholder:text-ink-4 outline-none focus:border-brand focus:ring-1 focus:ring-brand/20"
/>
</div>
<div className="max-h-64 overflow-y-auto">
{filteredZones.map((zone) => {
const isSelected = String(zone.hubid) === String(selectedZoneId);
const isAll = zone.hubid === 'all';
return (
<DropdownMenuItem
key={zone.hubid}
onClick={() => setSelectedZoneId(zone.hubid)}
className={cn(
'flex items-center justify-between gap-2 px-2 py-1.5 text-xs cursor-pointer rounded-md',
isSelected && 'bg-brand-tint/60 text-brand font-semibold'
)}
>
<div className="flex items-center gap-2 min-w-0">
{isAll ? (
<Globe className={cn('h-3.5 w-3.5 shrink-0', isSelected ? 'text-brand' : 'text-ink-3')} />
) : zone.hubtype === 'sorting_center' ? (
<Warehouse className={cn('h-3.5 w-3.5 shrink-0', isSelected ? 'text-brand' : 'text-ink-3')} />
) : (
<Building2 className={cn('h-3.5 w-3.5 shrink-0', isSelected ? 'text-brand' : 'text-ink-3')} />
)}
<div className="min-w-0 truncate">
<p className="truncate text-xs font-medium text-ink-1">
{zone.hubname}
</p>
{!isAll && (zone.city || zone.pincode) && (
<p className="truncate text-[10px] text-ink-4">
{[zone.city, zone.pincode].filter(Boolean).join(' · ')}
</p>
)}
</div>
</div>
{isSelected && <Check className="h-3.5 w-3.5 text-brand shrink-0" />}
</DropdownMenuItem>
);
})}
{filteredZones.length === 0 && (
<p className="py-4 text-center text-xs text-ink-4">No matching zones found</p>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -133,3 +133,4 @@ export {
/* ── Layout helpers ─────────────────────────────────────────────────────── */ /* ── Layout helpers ─────────────────────────────────────────────────────── */
export { Grid, PageSection, Stack } from './Layout'; export { Grid, PageSection, Stack } from './Layout';
export { ZoneSelector } from './ZoneSelector';

197
src/lib/ZoneContext.jsx Normal file
View File

@@ -0,0 +1,197 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { useHubs, useTenantLocations } from '@/lib/doormileHooks';
import { useAuth } from '@/lib/AuthContext';
const ZONE_STORAGE_KEY = 'doormile_active_zone_id';
const ALL_ZONE = Object.freeze({
hubid: 'all',
id: 'all',
hubname: 'All Zones',
city: 'All Cities',
hubtype: 'all',
status: 'Active',
});
const haversineKm = (lat1, lon1, lat2, lon2) => {
if (![lat1, lon1, lat2, lon2].every((v) => Number.isFinite(Number(v)))) return undefined;
const R = 6371;
const toRad = (deg) => (Number(deg) * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
const ZoneContext = createContext(null);
export function ZoneProvider({ children }) {
const { user } = useAuth();
const tenantId = user?.tenantid || localStorage.getItem('tenantid') || '';
const isTenantUser = Boolean(tenantId && tenantId !== '0');
// Staff loads global hubs; Tenant loads tenant locations / kitchen hubs
const { data: rawHubs = [], isLoading: isHubsLoading, refetch: refetchHubs } = useHubs({
enabled: !isTenantUser,
});
const { data: tenantLocations = [], isLoading: isLocationsLoading, refetch: refetchLocations } = useTenantLocations(
isTenantUser ? tenantId : null,
{ enabled: isTenantUser }
);
const [selectedZoneId, setSelectedZoneIdState] = useState(() => {
try {
return localStorage.getItem(ZONE_STORAGE_KEY) || 'all';
} catch {
return 'all';
}
});
const activeHubs = useMemo(() => {
if (isTenantUser) {
return (tenantLocations || []).map((loc) => ({
hubid: String(loc.tenantlocationid || loc.locationid || loc.id),
id: String(loc.tenantlocationid || loc.locationid || loc.id),
hubname: loc.locationname || loc.name || `Kitchen Hub #${loc.tenantlocationid || loc.locationid}`,
city: loc.city || '',
address: loc.address || '',
latitude: loc.latitude,
longitude: loc.longitude,
pincode: loc.pincode || '',
hubtype: 'kitchen_hub',
isTenantLocation: true,
status: 'Active',
}));
}
return (rawHubs || []).filter(
(hub) => String(hub.status || 'Active').toLowerCase() === 'active'
);
}, [isTenantUser, tenantLocations, rawHubs]);
const zones = useMemo(() => {
return [ALL_ZONE, ...activeHubs];
}, [activeHubs]);
const selectedZone = useMemo(() => {
if (selectedZoneId === 'all') return ALL_ZONE;
const found = activeHubs.find((h) => String(h.hubid) === String(selectedZoneId));
return found || ALL_ZONE;
}, [selectedZoneId, activeHubs]);
const setSelectedZoneId = (id) => {
const nextId = String(id || 'all');
setSelectedZoneIdState(nextId);
try {
localStorage.setItem(ZONE_STORAGE_KEY, nextId);
} catch {
// ignore storage errors
}
};
const isAllZones = selectedZone.hubid === 'all';
/**
* Matches an order, delivery, tripsheet or rider against the active zone.
*/
const matchesZone = (item) => {
if (!item) return false;
// Tenant check: if item carries tenantid and current session is a tenant, match tenant first
if (isTenantUser && item.tenantid != null && String(item.tenantid) !== String(tenantId)) {
return false;
}
if (isAllZones) return true;
const currentHub = selectedZone;
const targetHubId = String(currentHub.hubid);
// 1. Direct hub/location id match
const itemHubId =
item.hubid ??
item.sourcehubid ??
item.applocationid ??
item.tenantlocationid ??
item.locationid;
if (itemHubId != null && String(itemHubId) === targetHubId) {
return true;
}
// 2. City or Hub name match
const hubCity = String(currentHub.city || '').trim().toLowerCase();
const hubName = String(currentHub.hubname || '').trim().toLowerCase();
const hubPincode = String(currentHub.pincode || '').trim();
const addressText = `${item.pickupaddress || ''} ${item.deliveryaddress || ''} ${item.address || ''} ${item.pickuplocation || ''} ${item.deliverylocation || ''} ${item.suburb || ''} ${item.city || ''}`.toLowerCase();
if (hubPincode && addressText.includes(hubPincode)) {
return true;
}
if (hubCity && addressText.includes(hubCity)) {
return true;
}
if (hubName && addressText.includes(hubName)) {
return true;
}
// 3. Coordinate distance match (within 35km radius of hub)
const hubLat = Number(currentHub.latitude);
const hubLon = Number(currentHub.longitude);
if (Number.isFinite(hubLat) && Number.isFinite(hubLon)) {
const pickLat = Number(item.pickuplatitude ?? item.latitude);
const pickLon = Number(item.pickuplongitude ?? item.longitude);
const dropLat = Number(item.deliverylatitude ?? item.droplat);
const dropLon = Number(item.deliverylongitude ?? item.droplon);
const pickDist = haversineKm(hubLat, hubLon, pickLat, pickLon);
const dropDist = haversineKm(hubLat, hubLon, dropLat, dropLon);
if (pickDist !== undefined && pickDist <= 35) return true;
if (dropDist !== undefined && dropDist <= 35) return true;
}
return false;
};
const value = useMemo(
() => ({
selectedZone,
selectedZoneId,
setSelectedZoneId,
zones,
isAllZones,
isTenantUser,
tenantId,
isHubsLoading: isTenantUser ? isLocationsLoading : isHubsLoading,
refetchHubs: isTenantUser ? refetchLocations : refetchHubs,
matchesZone,
}),
[
selectedZone,
selectedZoneId,
zones,
isAllZones,
isTenantUser,
tenantId,
isLocationsLoading,
isHubsLoading,
refetchLocations,
refetchHubs,
matchesZone,
]
);
return <ZoneContext.Provider value={value}>{children}</ZoneContext.Provider>;
}
export function useZone() {
const context = useContext(ZoneContext);
if (!context) {
throw new Error('useZone must be used within a ZoneProvider');
}
return context;
}

View File

@@ -3,7 +3,7 @@ import { Ban, Bell, Bike, FileSpreadsheet, Package, PackageSearch, Truck } from
import { import {
Alert, Button, DataTable, Drawer, EmptyState, Field, IconButton, Input, Alert, Button, DataTable, Drawer, EmptyState, Field, IconButton, Input,
PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal, PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal,
Stack, StatusBadge, Surface, Tabs, Textarea, Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector,
} from '@/components/ds'; } from '@/components/ds';
import { ListToolbar } from '@/components/doormile/ListToolbar'; import { ListToolbar } from '@/components/doormile/ListToolbar';
import { import {
@@ -11,6 +11,7 @@ import {
useDeliveryCounts, useNotifyMiler, useRiderOptions, useTenants, useDeliveryCounts, useNotifyMiler, useRiderOptions, useTenants,
useUpdateDeliveryStatus, useUpdateDeliveryStatus,
} from '@/lib/doormileHooks'; } from '@/lib/doormileHooks';
import { useZone } from '@/lib/ZoneContext';
import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket'; import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket';
import { formatDoormileTimestamp, parseDoormileTimestamp } from '@/lib/doormileTimestamp'; import { formatDoormileTimestamp, parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat'; import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
@@ -93,6 +94,7 @@ export default function Deliveries() {
const updateStatus = useUpdateDeliveryStatus(); const updateStatus = useUpdateDeliveryStatus();
const cancelDelivery = useCancelDelivery(); const cancelDelivery = useCancelDelivery();
const notifyMiler = useNotifyMiler(); const notifyMiler = useNotifyMiler();
const { selectedZone, isAllZones, matchesZone } = useZone();
const allRows = useMemo(() => { const allRows = useMemo(() => {
return [...(data?.rows || [])].sort((a, b) => { return [...(data?.rows || [])].sort((a, b) => {
@@ -102,20 +104,17 @@ export default function Deliveries() {
}); });
}, [data?.rows]); }, [data?.rows]);
/* Batch, client and rider first, then status, then search — the counts under /* Zone, Batch, client and rider first, then status, then search — the counts under
the tabs have to describe the slice the operator has chosen, not the whole the tabs have to describe the slice the operator has chosen, not the whole
day, or a tab reads 6 and shows 2. day, or a tab reads 6 and shows 2. */
There is deliberately no site filter: a delivery row carries its client but
no tenant-location id, so the only honest filter at that level would be one
that silently matched nothing. */
const batchRows = useMemo( const batchRows = useMemo(
() => () =>
allRows allRows
.filter(matchesZone)
.filter((row) => batch === 'all' || getRowBatchId(row) === batch) .filter((row) => batch === 'all' || getRowBatchId(row) === batch)
.filter((row) => tenantId === ALL || String(row.tenantid) === tenantId) .filter((row) => tenantId === ALL || String(row.tenantid) === tenantId)
.filter((row) => riderId === ALL || String(row.userid) === riderId), .filter((row) => riderId === ALL || String(row.userid) === riderId),
[allRows, batch, tenantId, riderId] [allRows, matchesZone, batch, tenantId, riderId]
); );
const unmapped = useMemo( const unmapped = useMemo(
@@ -399,6 +398,7 @@ export default function Deliveries() {
</Button> </Button>
} }
> >
<ZoneSelector className="w-full sm:w-auto" />
<Select value={batch} onValueChange={setBatch}> <Select value={batch} onValueChange={setBatch}>
<SelectTrigger className="w-full sm:w-52"> <SelectTrigger className="w-full sm:w-52">
<SelectValue /> <SelectValue />

View File

@@ -2,9 +2,10 @@ import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Ban, FileSpreadsheet, Package, Plus, Truck, Upload } from 'lucide-react'; import { Ban, FileSpreadsheet, MapPin, Package, Plus, Truck, Upload } from 'lucide-react';
import { Button, ConfirmModal, DataTable, EmptyState, PageHeader, SearchInput, Stack, StatusBadge, Tabs } from '@/components/ds'; import { Button, ConfirmModal, DataTable, EmptyState, PageHeader, SearchInput, Stack, StatusBadge, Tabs, ZoneSelector } from '@/components/ds';
import { useBulkCancelBookings, useCustomers } from '@/lib/doormileHooks'; import { useBulkCancelBookings, useCustomers } from '@/lib/doormileHooks';
import { useZone } from '@/lib/ZoneContext';
import { createAutomationDeliveries } from '@/api/doormile'; import { createAutomationDeliveries } from '@/api/doormile';
import { drainBookings } from '@/lib/assistant/scan'; import { drainBookings } from '@/lib/assistant/scan';
import { OpenToast, messageOf } from '@/api/doormile/notify'; import { OpenToast, messageOf } from '@/api/doormile/notify';
@@ -68,20 +69,13 @@ export default function Orders() {
const [selectedIds, setSelectedIds] = useState([]); const [selectedIds, setSelectedIds] = useState([]);
const [cancelOpen, setCancelOpen] = useState(false); const [cancelOpen, setCancelOpen] = useState(false);
const { selectedZone, selectedZoneId, isAllZones, matchesZone } = useZone();
const { data: customers = [] } = useCustomers(); const { data: customers = [] } = useCustomers();
const bulkCancel = useBulkCancelBookings(); const bulkCancel = useBulkCancelBookings();
/* Polled: an operator watching the pending queue needs new bookings to /* Polled: an operator watching the pending queue needs new bookings to
appear without reaching for refresh. */ appear without reaching for refresh. */
/* `getBookings(1, 1000)` used to back this screen. That is page ONE at the
API's hard cap, and it discards the envelope's `total`, so past 1000
lifetime bookings every tab here was computed from a partial list with no
way to know it. `/admin/bookings` documents no row order either (scan.js
detects it per call rather than assuming), so a freshly created order is
not guaranteed to be on page 1 — it could simply be absent from every tab.
Drain the pages instead, and surface `truncated` below rather than quietly
showing a short list. `cached: false` because TanStack owns freshness for
this screen; scan.js's own 20s page cache would cap the refetch interval. */
const { const {
data: scan = EMPTY_SCAN, data: scan = EMPTY_SCAN,
isLoading, isLoading,
@@ -104,6 +98,10 @@ export default function Orders() {
}); });
}, [bookings]); }, [bookings]);
const zoneBookings = useMemo(() => {
return sortedBookings.filter(matchesZone);
}, [sortedBookings, matchesZone]);
const customerMap = useMemo(() => { const customerMap = useMemo(() => {
const map = new Map(); const map = new Map();
(customers || []).forEach((customer) => { (customers || []).forEach((customer) => {
@@ -115,7 +113,7 @@ export default function Orders() {
const counts = useMemo(() => { const counts = useMemo(() => {
const byStatus = {}; const byStatus = {};
(sortedBookings || []).forEach((booking) => { (zoneBookings || []).forEach((booking) => {
const status = String(booking.status || '').toLowerCase(); const status = String(booking.status || '').toLowerCase();
byStatus[status] = (byStatus[status] || 0) + 1; byStatus[status] = (byStatus[status] || 0) + 1;
}); });
@@ -125,25 +123,25 @@ export default function Orders() {
statusesInGroup(group).reduce((sum, status) => sum + (byStatus[status] || 0), 0), statusesInGroup(group).reduce((sum, status) => sum + (byStatus[status] || 0), 0),
]) ])
); );
}, [sortedBookings]); }, [zoneBookings]);
const tabStatuses = useMemo(() => statusesInGroup(tab), [tab]); const tabStatuses = useMemo(() => statusesInGroup(tab), [tab]);
const rows = useMemo( const rows = useMemo(
() => () =>
(sortedBookings || []) (zoneBookings || [])
.filter((booking) => tabStatuses.includes(String(booking.status || '').toLowerCase())) .filter((booking) => tabStatuses.includes(String(booking.status || '').toLowerCase()))
.filter((booking) => .filter((booking) =>
matchesQuery(booking, ['bookingno', 'pickupaddress', 'deliveryaddress', 'notes'], debouncedSearch) matchesQuery(booking, ['bookingno', 'pickupaddress', 'deliveryaddress', 'notes'], debouncedSearch)
), ),
[sortedBookings, tabStatuses, debouncedSearch] [zoneBookings, tabStatuses, debouncedSearch]
); );
/* A row that scrolled out of the current tab must not stay silently selected /* A row that scrolled out of the current tab must not stay silently selected
— the assign buttons act on the selection, not on what is on screen. */ — the assign buttons act on the selection, not on what is on screen. */
useEffect(() => { useEffect(() => {
setSelectedIds([]); setSelectedIds([]);
}, [tab]); }, [tab, selectedZoneId]);
const selectedOrders = useMemo( const selectedOrders = useMemo(
() => rows.filter((row) => selectedIds.includes(row.bookingid)), () => rows.filter((row) => selectedIds.includes(row.bookingid)),
@@ -168,6 +166,8 @@ export default function Orders() {
selectedMode: { value: 1 }, selectedMode: { value: 1 },
deliveryData: variables?.deliveries || [], deliveryData: variables?.deliveries || [],
startdate: dayjs().format('YYYY-MM-DD'), startdate: dayjs().format('YYYY-MM-DD'),
hubid: selectedZone.hubid !== 'all' ? selectedZone.hubid : undefined,
zoneName: selectedZone.hubname,
}, },
}); });
}, },
@@ -176,6 +176,11 @@ export default function Orders() {
const assignOrders = () => { const assignOrders = () => {
if (!selectedOrders.length) return; if (!selectedOrders.length) return;
if (isAllZones) {
OpenToast('Please select a specific Zone / Hub before assigning orders.', 'warning', 4000);
return;
}
const deliveries = selectedOrders.map((booking) => { const deliveries = selectedOrders.map((booking) => {
const charge = chargeOf(booking); const charge = chargeOf(booking);
return { return {
@@ -195,6 +200,7 @@ export default function Orders() {
deliverycharges: charge, deliverycharges: charge,
pickuplocation: booking.pickupaddress, pickuplocation: booking.pickupaddress,
deliverylocation: booking.deliveryaddress, deliverylocation: booking.deliveryaddress,
hubid: selectedZone.hubid !== 'all' ? selectedZone.hubid : booking.hubid,
}; };
}); });
@@ -203,6 +209,7 @@ export default function Orders() {
selectedMode: { value: 1 }, selectedMode: { value: 1 },
hypertuning_params: 'balanced', hypertuning_params: 'balanced',
absent_riders: [], absent_riders: [],
hubid: selectedZone.hubid !== 'all' ? selectedZone.hubid : undefined,
}); });
}; };
@@ -338,11 +345,12 @@ export default function Orders() {
</div> </div>
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none"> <div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none">
<ZoneSelector />
<SearchInput <SearchInput
value={search} value={search}
onChange={setSearch} onChange={setSearch}
placeholder="Search by order number, address or note" placeholder="Search by order number, address or note"
className="w-full sm:w-72" className="w-full sm:w-64"
/> />
{selectedIds.length > 0 && tab === 'pending' && ( {selectedIds.length > 0 && tab === 'pending' && (
<Button variant="outline" onClick={() => setCancelOpen(true)}> <Button variant="outline" onClick={() => setCancelOpen(true)}>
@@ -359,21 +367,43 @@ export default function Orders() {
</div> </div>
</div> </div>
<p className="text-caption text-ink-3"> {tab === 'pending' && isAllZones && (
{rows.length === (counts[tab] ?? 0) <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50/80 p-3 text-body-sm text-amber-900 shadow-2xs">
? `${counts[tab] ?? 0} orders` <div className="flex items-center gap-2.5 min-w-0">
: `${rows.length} of ${counts[tab] ?? 0} orders`} <div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-amber-100 text-amber-800">
{/* A capped scan means an order really can be missing from this tab. <MapPin className="h-4 w-4" />
Say so — a silently short list is what sent operators hunting for </div>
an order they had just created. */} <div className="min-w-0">
{scan.truncated && ( <p className="text-xs font-bold text-amber-950">Select an Operational Zone for Dispatch Assignment</p>
<span className="text-warning"> <p className="text-[11px] text-amber-800 truncate sm:whitespace-normal">
{` — showing the first ${scan.scanned.toLocaleString('en-IN')} of ${scan.total.toLocaleString( To select and assign pending orders to riders, please choose a specific Hub/Zone (e.g. Coimbatore Neptune Hub) first.
'en-IN' </p>
)} bookings, so this count is a floor. Search by order number to find a specific order.`} </div>
</span> </div>
)} <ZoneSelector width="w-64" className="border-amber-300 bg-white" />
</p> </div>
)}
<div className="flex items-center justify-between gap-2">
<p className="text-caption text-ink-3">
{rows.length === (counts[tab] ?? 0)
? `${counts[tab] ?? 0} orders`
: `${rows.length} of ${counts[tab] ?? 0} orders`}
{!isAllZones && (
<span className="font-medium text-ink-2"> · Zone: {selectedZone.hubname}</span>
)}
{/* A capped scan means an order really can be missing from this tab.
Say so — a silently short list is what sent operators hunting for
an order they had just created. */}
{scan.truncated && (
<span className="text-warning">
{` — showing the first ${scan.scanned.toLocaleString('en-IN')} of ${scan.total.toLocaleString(
'en-IN'
)} bookings, so this count is a floor. Search by order number to find a specific order.`}
</span>
)}
</p>
</div>
<DataTable <DataTable
columns={columns} columns={columns}
@@ -381,8 +411,8 @@ export default function Orders() {
getRowId={(row) => row.bookingid} getRowId={(row) => row.bookingid}
loading={isLoading} loading={isLoading}
refreshing={isFetching && !isLoading} refreshing={isFetching && !isLoading}
selectedIds={tab === 'pending' ? selectedIds : undefined} selectedIds={tab === 'pending' && !isAllZones ? selectedIds : undefined}
onSelectionChange={tab === 'pending' ? setSelectedIds : undefined} onSelectionChange={tab === 'pending' && !isAllZones ? setSelectedIds : undefined}
pageSize={25} pageSize={25}
/* No defaultSort: `sortedBookings` above already orders these newest /* No defaultSort: `sortedBookings` above already orders these newest
first by `bookingid`, and DataTable leaves rows untouched when it first by `bookingid`, and DataTable leaves rows untouched when it

View File

@@ -7,12 +7,13 @@ import {
import { import {
Avatar, Button, ConfirmModal, DataTable, Drawer, EmptyState, Field, Grid, IconButton, Avatar, Button, ConfirmModal, DataTable, Drawer, EmptyState, Field, Grid, IconButton,
Input, KpiCard, Modal, PageHeader, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, Input, KpiCard, Modal, PageHeader, SearchInput, Select, SelectContent, SelectItem, SelectTrigger,
SelectValue, Stack, StatusBadge, Surface, Tabs, Textarea, SelectValue, Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector,
} from '@/components/ds'; } from '@/components/ds';
import { import {
useAssignMilerVehicle, useBlockMiler, useMilerLogs, useMilers, useNotifyMiler, useAssignMilerVehicle, useBlockMiler, useMilerLogs, useMilers, useNotifyMiler,
useRiderSummaryCounts, useVehicles, useRiderSummaryCounts, useVehicles,
} from '@/lib/doormileHooks'; } from '@/lib/doormileHooks';
import { useZone } from '@/lib/ZoneContext';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp'; import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { currency, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat'; import { currency, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
@@ -64,17 +65,23 @@ export default function Riders() {
const assignVehicle = useAssignMilerVehicle(); const assignVehicle = useAssignMilerVehicle();
const notifyMiler = useNotifyMiler(); const notifyMiler = useNotifyMiler();
const { selectedZone, isAllZones, matchesZone } = useZone();
const zoneRiders = useMemo(() => {
return (riders || []).filter(matchesZone);
}, [riders, matchesZone]);
const counts = useMemo(() => { const counts = useMemo(() => {
const list = riders || []; const list = zoneRiders;
return { return {
all: list.length, all: list.length,
active: list.filter(isActive).length, active: list.filter(isActive).length,
blocked: list.filter((rider) => String(rider.availabilitystatus || '').toLowerCase() === 'blocked').length, blocked: list.filter((rider) => String(rider.availabilitystatus || '').toLowerCase() === 'blocked').length,
}; };
}, [riders]); }, [zoneRiders]);
const rows = useMemo(() => { const rows = useMemo(() => {
let list = riders || []; let list = zoneRiders;
if (tab === 'active') list = list.filter(isActive); if (tab === 'active') list = list.filter(isActive);
else if (tab === 'blocked') { else if (tab === 'blocked') {
list = list.filter((rider) => String(rider.availabilitystatus || '').toLowerCase() === 'blocked'); list = list.filter((rider) => String(rider.availabilitystatus || '').toLowerCase() === 'blocked');
@@ -82,7 +89,7 @@ export default function Riders() {
return list.filter((rider) => return list.filter((rider) =>
matchesQuery(rider, [riderName, 'phone', 'userid', 'city', 'suburb', 'defaultvehicletype'], debouncedSearch) matchesQuery(rider, [riderName, 'phone', 'userid', 'city', 'suburb', 'defaultvehicletype'], debouncedSearch)
); );
}, [riders, tab, debouncedSearch]); }, [zoneRiders, tab, debouncedSearch]);
const columns = useMemo( const columns = useMemo(
() => [ () => [
@@ -218,7 +225,7 @@ export default function Riders() {
/> />
<KpiCard <KpiCard
icon={CheckCircle2} icon={CheckCircle2}
value={summary?.available ?? 0} value={summary?.available ?? counts.active}
label="Available now" label="Available now"
tone="success" tone="success"
index={1} index={1}
@@ -226,7 +233,14 @@ export default function Riders() {
/> />
<KpiCard <KpiCard
icon={Bike} icon={Bike}
value={summary?.onDelivery ?? 0} value={
summary?.onDelivery ??
zoneRiders.filter(
(r) =>
String(r.availabilitystatus || '').toLowerCase() === 'on_delivery' ||
String(r.availabilitystatus || '').toLowerCase() === 'ondelivery'
).length
}
label="On delivery" label="On delivery"
tone="accent" tone="accent"
index={2} index={2}
@@ -234,7 +248,7 @@ export default function Riders() {
/> />
<KpiCard <KpiCard
icon={CircleSlash} icon={CircleSlash}
value={summary?.inactive ?? 0} value={summary?.inactive ?? counts.blocked}
label="Offline or blocked" label="Offline or blocked"
tone="neutral" tone="neutral"
index={3} index={3}
@@ -256,12 +270,13 @@ export default function Riders() {
/> />
</div> </div>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 sm:flex-none"> <div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none">
<ZoneSelector />
<SearchInput <SearchInput
value={search} value={search}
onChange={setSearch} onChange={setSearch}
placeholder="Search by name, phone, id or city" placeholder="Search by name, phone, id or city"
className="w-full sm:w-72" className="w-full sm:w-64"
/> />
</div> </div>
</div> </div>