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 PageNotFound from './lib/PageNotFound';
import { AuthProvider, useAuth } from '@/lib/AuthContext';
import { ZoneProvider } from '@/lib/ZoneContext';
import ScrollToTop from './components/ScrollToTop';
import ProtectedRoute from '@/components/ProtectedRoute';
import AdminLayout from '@/layouts/AdminLayout';
@@ -131,15 +132,17 @@ function App() {
return (
<AuthProvider>
<QueryClientProvider client={queryClientInstance}>
<Router>
<ScrollToTop />
<AuthenticatedApp />
</Router>
<Toaster />
{/* The design system's `toast` helper wraps react-hot-toast, which needs
its own renderer mounted. Without this every toast in the app fires
into nothing — including the data layer's failure notices. */}
<HotToaster position="bottom-right" />
<ZoneProvider>
<Router>
<ScrollToTop />
<AuthenticatedApp />
</Router>
<Toaster />
{/* The design system's `toast` helper wraps react-hot-toast, which needs
its own renderer mounted. Without this every toast in the app fires
into nothing — including the data layer's failure notices. */}
<HotToaster position="bottom-right" />
</ZoneProvider>
</QueryClientProvider>
</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 ─────────────────────────────────────────────────────── */
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 {
Alert, Button, DataTable, Drawer, EmptyState, Field, IconButton, Input,
PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal,
Stack, StatusBadge, Surface, Tabs, Textarea,
Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector,
} from '@/components/ds';
import { ListToolbar } from '@/components/doormile/ListToolbar';
import {
@@ -11,6 +11,7 @@ import {
useDeliveryCounts, useNotifyMiler, useRiderOptions, useTenants,
useUpdateDeliveryStatus,
} from '@/lib/doormileHooks';
import { useZone } from '@/lib/ZoneContext';
import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket';
import { formatDoormileTimestamp, parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
@@ -93,6 +94,7 @@ export default function Deliveries() {
const updateStatus = useUpdateDeliveryStatus();
const cancelDelivery = useCancelDelivery();
const notifyMiler = useNotifyMiler();
const { selectedZone, isAllZones, matchesZone } = useZone();
const allRows = useMemo(() => {
return [...(data?.rows || [])].sort((a, b) => {
@@ -102,20 +104,17 @@ export default function Deliveries() {
});
}, [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
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. */
day, or a tab reads 6 and shows 2. */
const batchRows = useMemo(
() =>
allRows
.filter(matchesZone)
.filter((row) => batch === 'all' || getRowBatchId(row) === batch)
.filter((row) => tenantId === ALL || String(row.tenantid) === tenantId)
.filter((row) => riderId === ALL || String(row.userid) === riderId),
[allRows, batch, tenantId, riderId]
[allRows, matchesZone, batch, tenantId, riderId]
);
const unmapped = useMemo(
@@ -399,6 +398,7 @@ export default function Deliveries() {
</Button>
}
>
<ZoneSelector className="w-full sm:w-auto" />
<Select value={batch} onValueChange={setBatch}>
<SelectTrigger className="w-full sm:w-52">
<SelectValue />

View File

@@ -2,9 +2,10 @@ import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { motion } from 'framer-motion';
import dayjs from 'dayjs';
import { Ban, FileSpreadsheet, Package, Plus, Truck, Upload } from 'lucide-react';
import { Button, ConfirmModal, DataTable, EmptyState, PageHeader, SearchInput, Stack, StatusBadge, Tabs } from '@/components/ds';
import { Ban, FileSpreadsheet, MapPin, Package, Plus, Truck, Upload } from 'lucide-react';
import { Button, ConfirmModal, DataTable, EmptyState, PageHeader, SearchInput, Stack, StatusBadge, Tabs, ZoneSelector } from '@/components/ds';
import { useBulkCancelBookings, useCustomers } from '@/lib/doormileHooks';
import { useZone } from '@/lib/ZoneContext';
import { createAutomationDeliveries } from '@/api/doormile';
import { drainBookings } from '@/lib/assistant/scan';
import { OpenToast, messageOf } from '@/api/doormile/notify';
@@ -68,20 +69,13 @@ export default function Orders() {
const [selectedIds, setSelectedIds] = useState([]);
const [cancelOpen, setCancelOpen] = useState(false);
const { selectedZone, selectedZoneId, isAllZones, matchesZone } = useZone();
const { data: customers = [] } = useCustomers();
const bulkCancel = useBulkCancelBookings();
/* Polled: an operator watching the pending queue needs new bookings to
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 {
data: scan = EMPTY_SCAN,
isLoading,
@@ -104,6 +98,10 @@ export default function Orders() {
});
}, [bookings]);
const zoneBookings = useMemo(() => {
return sortedBookings.filter(matchesZone);
}, [sortedBookings, matchesZone]);
const customerMap = useMemo(() => {
const map = new Map();
(customers || []).forEach((customer) => {
@@ -115,7 +113,7 @@ export default function Orders() {
const counts = useMemo(() => {
const byStatus = {};
(sortedBookings || []).forEach((booking) => {
(zoneBookings || []).forEach((booking) => {
const status = String(booking.status || '').toLowerCase();
byStatus[status] = (byStatus[status] || 0) + 1;
});
@@ -125,25 +123,25 @@ export default function Orders() {
statusesInGroup(group).reduce((sum, status) => sum + (byStatus[status] || 0), 0),
])
);
}, [sortedBookings]);
}, [zoneBookings]);
const tabStatuses = useMemo(() => statusesInGroup(tab), [tab]);
const rows = useMemo(
() =>
(sortedBookings || [])
(zoneBookings || [])
.filter((booking) => tabStatuses.includes(String(booking.status || '').toLowerCase()))
.filter((booking) =>
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
— the assign buttons act on the selection, not on what is on screen. */
useEffect(() => {
setSelectedIds([]);
}, [tab]);
}, [tab, selectedZoneId]);
const selectedOrders = useMemo(
() => rows.filter((row) => selectedIds.includes(row.bookingid)),
@@ -168,6 +166,8 @@ export default function Orders() {
selectedMode: { value: 1 },
deliveryData: variables?.deliveries || [],
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 = () => {
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 charge = chargeOf(booking);
return {
@@ -195,6 +200,7 @@ export default function Orders() {
deliverycharges: charge,
pickuplocation: booking.pickupaddress,
deliverylocation: booking.deliveryaddress,
hubid: selectedZone.hubid !== 'all' ? selectedZone.hubid : booking.hubid,
};
});
@@ -203,6 +209,7 @@ export default function Orders() {
selectedMode: { value: 1 },
hypertuning_params: 'balanced',
absent_riders: [],
hubid: selectedZone.hubid !== 'all' ? selectedZone.hubid : undefined,
});
};
@@ -338,11 +345,12 @@ export default function Orders() {
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none">
<ZoneSelector />
<SearchInput
value={search}
onChange={setSearch}
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' && (
<Button variant="outline" onClick={() => setCancelOpen(true)}>
@@ -359,21 +367,43 @@ export default function Orders() {
</div>
</div>
<p className="text-caption text-ink-3">
{rows.length === (counts[tab] ?? 0)
? `${counts[tab] ?? 0} orders`
: `${rows.length} of ${counts[tab] ?? 0} orders`}
{/* 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>
{tab === 'pending' && isAllZones && (
<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">
<div className="flex items-center gap-2.5 min-w-0">
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-amber-100 text-amber-800">
<MapPin className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-xs font-bold text-amber-950">Select an Operational Zone for Dispatch Assignment</p>
<p className="text-[11px] text-amber-800 truncate sm:whitespace-normal">
To select and assign pending orders to riders, please choose a specific Hub/Zone (e.g. Coimbatore Neptune Hub) first.
</p>
</div>
</div>
<ZoneSelector width="w-64" className="border-amber-300 bg-white" />
</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
columns={columns}
@@ -381,8 +411,8 @@ export default function Orders() {
getRowId={(row) => row.bookingid}
loading={isLoading}
refreshing={isFetching && !isLoading}
selectedIds={tab === 'pending' ? selectedIds : undefined}
onSelectionChange={tab === 'pending' ? setSelectedIds : undefined}
selectedIds={tab === 'pending' && !isAllZones ? selectedIds : undefined}
onSelectionChange={tab === 'pending' && !isAllZones ? setSelectedIds : undefined}
pageSize={25}
/* No defaultSort: `sortedBookings` above already orders these newest
first by `bookingid`, and DataTable leaves rows untouched when it

View File

@@ -7,12 +7,13 @@ import {
import {
Avatar, Button, ConfirmModal, DataTable, Drawer, EmptyState, Field, Grid, IconButton,
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';
import {
useAssignMilerVehicle, useBlockMiler, useMilerLogs, useMilers, useNotifyMiler,
useRiderSummaryCounts, useVehicles,
} from '@/lib/doormileHooks';
import { useZone } from '@/lib/ZoneContext';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { currency, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
@@ -64,17 +65,23 @@ export default function Riders() {
const assignVehicle = useAssignMilerVehicle();
const notifyMiler = useNotifyMiler();
const { selectedZone, isAllZones, matchesZone } = useZone();
const zoneRiders = useMemo(() => {
return (riders || []).filter(matchesZone);
}, [riders, matchesZone]);
const counts = useMemo(() => {
const list = riders || [];
const list = zoneRiders;
return {
all: list.length,
active: list.filter(isActive).length,
blocked: list.filter((rider) => String(rider.availabilitystatus || '').toLowerCase() === 'blocked').length,
};
}, [riders]);
}, [zoneRiders]);
const rows = useMemo(() => {
let list = riders || [];
let list = zoneRiders;
if (tab === 'active') list = list.filter(isActive);
else if (tab === 'blocked') {
list = list.filter((rider) => String(rider.availabilitystatus || '').toLowerCase() === 'blocked');
@@ -82,7 +89,7 @@ export default function Riders() {
return list.filter((rider) =>
matchesQuery(rider, [riderName, 'phone', 'userid', 'city', 'suburb', 'defaultvehicletype'], debouncedSearch)
);
}, [riders, tab, debouncedSearch]);
}, [zoneRiders, tab, debouncedSearch]);
const columns = useMemo(
() => [
@@ -218,7 +225,7 @@ export default function Riders() {
/>
<KpiCard
icon={CheckCircle2}
value={summary?.available ?? 0}
value={summary?.available ?? counts.active}
label="Available now"
tone="success"
index={1}
@@ -226,7 +233,14 @@ export default function Riders() {
/>
<KpiCard
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"
tone="accent"
index={2}
@@ -234,7 +248,7 @@ export default function Riders() {
/>
<KpiCard
icon={CircleSlash}
value={summary?.inactive ?? 0}
value={summary?.inactive ?? counts.blocked}
label="Offline or blocked"
tone="neutral"
index={3}
@@ -256,12 +270,13 @@ export default function Riders() {
/>
</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
value={search}
onChange={setSearch}
placeholder="Search by name, phone, id or city"
className="w-full sm:w-72"
className="w-full sm:w-64"
/>
</div>
</div>