From 656e1806754939ac062ddf3888d8893291bf183d Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Thu, 27 Aug 2026 12:03:12 +0530 Subject: [PATCH] updates on the zone selector for the specific login --- src/App.jsx | 21 +- src/components/ds/ZoneSelector.jsx | 118 +++++++++++ src/components/ds/index.js | 1 + src/lib/ZoneContext.jsx | 197 +++++++++++++++++++ src/pages/doormile/deliveries/Deliveries.jsx | 16 +- src/pages/doormile/orders/Orders.jsx | 98 +++++---- src/pages/doormile/riders/Riders.jsx | 35 +++- 7 files changed, 425 insertions(+), 61 deletions(-) create mode 100644 src/components/ds/ZoneSelector.jsx create mode 100644 src/lib/ZoneContext.jsx diff --git a/src/App.jsx b/src/App.jsx index f073e37..d773b9a 100644 --- a/src/App.jsx +++ b/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 ( - - - - - - {/* 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. */} - + + + + + + + {/* 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. */} + + ); diff --git a/src/components/ds/ZoneSelector.jsx b/src/components/ds/ZoneSelector.jsx new file mode 100644 index 0000000..a6acd48 --- /dev/null +++ b/src/components/ds/ZoneSelector.jsx @@ -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 ( + !open && setSearch('')}> + + + + + + + Operational Zone / Hub + + +
+ + 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" + /> +
+ +
+ {filteredZones.map((zone) => { + const isSelected = String(zone.hubid) === String(selectedZoneId); + const isAll = zone.hubid === 'all'; + + return ( + 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' + )} + > +
+ {isAll ? ( + + ) : zone.hubtype === 'sorting_center' ? ( + + ) : ( + + )} +
+

+ {zone.hubname} +

+ {!isAll && (zone.city || zone.pincode) && ( +

+ {[zone.city, zone.pincode].filter(Boolean).join(' · ')} +

+ )} +
+
+ + {isSelected && } +
+ ); + })} + + {filteredZones.length === 0 && ( +

No matching zones found

+ )} +
+
+
+ ); +} diff --git a/src/components/ds/index.js b/src/components/ds/index.js index 285debc..8e5f2b5 100644 --- a/src/components/ds/index.js +++ b/src/components/ds/index.js @@ -133,3 +133,4 @@ export { /* ── Layout helpers ─────────────────────────────────────────────────────── */ export { Grid, PageSection, Stack } from './Layout'; +export { ZoneSelector } from './ZoneSelector'; diff --git a/src/lib/ZoneContext.jsx b/src/lib/ZoneContext.jsx new file mode 100644 index 0000000..a808b1e --- /dev/null +++ b/src/lib/ZoneContext.jsx @@ -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 {children}; +} + +export function useZone() { + const context = useContext(ZoneContext); + if (!context) { + throw new Error('useZone must be used within a ZoneProvider'); + } + return context; +} diff --git a/src/pages/doormile/deliveries/Deliveries.jsx b/src/pages/doormile/deliveries/Deliveries.jsx index df075fb..ed7ac77 100644 --- a/src/pages/doormile/deliveries/Deliveries.jsx +++ b/src/pages/doormile/deliveries/Deliveries.jsx @@ -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() { } > +