updates on the zone selector for the specific login
This commit is contained in:
21
src/App.jsx
21
src/App.jsx
@@ -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>
|
||||
);
|
||||
|
||||
118
src/components/ds/ZoneSelector.jsx
Normal file
118
src/components/ds/ZoneSelector.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -133,3 +133,4 @@ export {
|
||||
|
||||
/* ── Layout helpers ─────────────────────────────────────────────────────── */
|
||||
export { Grid, PageSection, Stack } from './Layout';
|
||||
export { ZoneSelector } from './ZoneSelector';
|
||||
|
||||
197
src/lib/ZoneContext.jsx
Normal file
197
src/lib/ZoneContext.jsx
Normal 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;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user