From 7aca6a6a485963d82f76c7d2c654297071755c1c Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Tue, 6 Oct 2026 19:21:06 +0530 Subject: [PATCH] updates on the admin and zone things and minor changes --- src/App.jsx | 23 ++-- src/layouts/AdminLayout.jsx | 23 ++-- src/lib/ZoneContext.jsx | 59 ++++++--- src/lib/clientOnboarding.js | 66 ++++++++++ src/lib/consoleNav.js | 37 ++++++ .../doormile/clients/ClientLoginDialogs.jsx | 65 +++++++++- .../doormile/clients/ClientOnboarding.jsx | 68 +++++++++- src/pages/doormile/exceptions/Exceptions.jsx | 22 ++-- src/pages/doormile/home/Home.jsx | 52 ++++---- src/pages/doormile/hubs/Hubs.jsx | 26 ++-- tests/integration/clientOnboarding.test.jsx | 116 +++++++++++++++++- tests/lib/ZoneContext.test.jsx | 35 +++++- tests/lib/consoleNav.test.js | 51 ++++++++ tests/lib/zoneHubIds.test.jsx | 36 +++++- 14 files changed, 589 insertions(+), 90 deletions(-) create mode 100644 src/lib/consoleNav.js create mode 100644 tests/lib/consoleNav.test.js diff --git a/src/App.jsx b/src/App.jsx index 9075d3c..e79f02f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -14,6 +14,13 @@ import Login from '@/pages/Login'; /* Every console page is code-split. The Dispatch board alone pulls the map, the solver payload builders and the comparison panels; loading that on the way to the Hubs table would make every first paint pay for it. */ +/* Doormile-only pages. A client login is sent home rather than shown a page + whose every request the server refuses. */ +function StaffOnly({ children }) { + const { isClient } = useAuth(); + return isClient ? : children; +} + const Dispatch = lazy(() => import('@/pages/doormile/dispatch/Dispatch.jsx')); const DispatchPreview = lazy(() => import('@/pages/doormile/dispatch/Preview.jsx')); @@ -126,10 +133,10 @@ const AuthenticatedApp = () => { {/* Reverse logistics: parcels going back to the sender (RTO). */} } /> - } /> - } /> + } /> + } /> {/* Owner-only (admin@doormile.com); the page and the server both check. */} - } /> + } /> } /> } /> @@ -145,11 +152,11 @@ const AuthenticatedApp = () => { } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> - } /> + } /> } /> } /> @@ -162,7 +169,7 @@ const AuthenticatedApp = () => { {/* The AI_engine swarm. Read-only: it reports what the agents are and what they last did, and has no control surface, because every autonomy gate lives in that repository's own env. */} - } /> + } /> diff --git a/src/layouts/AdminLayout.jsx b/src/layouts/AdminLayout.jsx index caaf0a3..1351112 100644 --- a/src/layouts/AdminLayout.jsx +++ b/src/layouts/AdminLayout.jsx @@ -17,6 +17,7 @@ import { import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { useAuth } from '@/lib/AuthContext'; import { canOnboardClients } from '@/lib/clientOnboarding'; +import { CLIENT_ONBOARDING_PATH, navGroupsFor } from '@/lib/consoleNav'; import { useExceptions } from '@/lib/doormileHooks'; import { formatDoormileTimestamp } from '@/lib/doormileTimestamp'; import AIPanel from '@/components/assistant/DoormileAI/AIPanel'; @@ -260,19 +261,15 @@ export default function AdminLayout() { const canOnboard = canOnboardClients(user, isClient); - const navGroups = useMemo(() => { - if (isClient) { - return NAV_GROUPS.filter((group) => group.label !== 'Fleet Ops'); - } - if (!canOnboard) return NAV_GROUPS; - // Only the onboarding owner login sees this entry; the server refuses - // everyone else regardless. - return NAV_GROUPS.map((group) => - group.label === 'Fleet Ops' - ? { ...group, items: [...group.items, { label: 'Client Onboarding', path: '/doormile/clients/onboard', icon: UserPlus }] } - : group - ); - }, [isClient, canOnboard]); + const navGroups = useMemo( + () => + navGroupsFor(NAV_GROUPS, { + isClient, + canOnboard, + onboardingItem: { label: 'Client Onboarding', path: CLIENT_ONBOARDING_PATH, icon: UserPlus }, + }), + [isClient, canOnboard] + ); const allDestinations = useMemo(() => { return [...navItems, ...navGroups.flatMap((group) => group.items)]; diff --git a/src/lib/ZoneContext.jsx b/src/lib/ZoneContext.jsx index 611cec9..106f989 100644 --- a/src/lib/ZoneContext.jsx +++ b/src/lib/ZoneContext.jsx @@ -23,10 +23,16 @@ export function ZoneProvider({ children }) { 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, - }); + // A client login's city (from the login response), used to offer them the + // Doormile hubs of their own city. Missing on a session signed in before the + // backend sent it, in which case every active hub is offered. + const clientCityId = user?.applocationid != null && String(user.applocationid) !== '0' ? String(user.applocationid) : ''; + + // Staff see Doormile's hubs. A client sees their own locations (kitchens, + // depots) AND the Doormile hubs of their city: a newly onboarded client had + // no locations at all, so their zone list was just "All Zones" and the + // Coimbatore hub their orders run through never appeared. + const { data: rawHubs = [], isLoading: isHubsLoading, refetch: refetchHubs } = useHubs(); const { data: tenantLocations = [], isLoading: isLocationsLoading, refetch: refetchLocations } = useTenantLocations( isTenantUser ? tenantId : null, { enabled: isTenantUser } @@ -41,10 +47,21 @@ export function ZoneProvider({ children }) { }); const activeHubs = useMemo(() => { + const doormileHubs = (rawHubs || []).filter( + (hub) => String(hub.status || 'Active').toLowerCase() === 'active' + ); if (isTenantUser) { - return (tenantLocations || []).map((loc) => ({ - hubid: String(loc.tenantlocationid || loc.locationid || loc.id), - id: String(loc.tenantlocationid || loc.locationid || loc.id), + // A client location's id and a hub id are separate numberings, so the + // location's zone id is prefixed; `locationid` keeps the raw one for + // matching orders (matchesZone). + // A deactivated location is no longer a place the client ships from; + // it was listed (and labelled Active) regardless until now. + const own = (tenantLocations || []) + .filter((loc) => String(loc.status || 'Active').toLowerCase() === 'active') + .map((loc) => ({ + hubid: `loc:${loc.tenantlocationid || loc.locationid || loc.id}`, + id: `loc:${loc.tenantlocationid || loc.locationid || loc.id}`, + locationid: String(loc.tenantlocationid || loc.locationid || loc.id), hubname: loc.locationname || loc.name || `Kitchen Hub #${loc.tenantlocationid || loc.locationid}`, city: loc.city || '', address: loc.address || '', @@ -55,12 +72,14 @@ export function ZoneProvider({ children }) { isTenantLocation: true, status: 'Active', })); + const cityHubs = clientCityId + ? doormileHubs.filter((hub) => String(hub.applocationid ?? '') === clientCityId) + : doormileHubs; + return [...own, ...cityHubs]; } - return (rawHubs || []).filter( - (hub) => String(hub.status || 'Active').toLowerCase() === 'active' - ); - }, [isTenantUser, tenantLocations, rawHubs]); + return doormileHubs; + }, [isTenantUser, tenantLocations, rawHubs, clientCityId]); const zones = useMemo(() => { return [ALL_ZONE, ...activeHubs]; @@ -178,10 +197,15 @@ export function ZoneProvider({ children }) { // The old `??` chain hid this by stopping at the first non-null field, so // the collision only fired when the earlier ones happened to be absent. It // was a bug waiting on its inputs, not a safe design. - const itemHubIds = isTenantUser + // + // What decides the id space is the selected ZONE, not who is signed in: a + // client can now pick one of their own locations (compare location ids) + // or a Doormile hub (compare hub ids). + const itemHubIds = currentHub.isTenantLocation ? [item.tenantlocationid, item.locationid] : [item.servicinghubid, item.pickuphubid, item.nearesthubid, item.hubid, item.sourcehubid]; - if (itemHubIds.some((id) => id != null && String(id) === targetHubId)) { + const targetId = currentHub.isTenantLocation ? String(currentHub.locationid) : targetHubId; + if (itemHubIds.some((id) => id != null && String(id) === targetId)) { return true; } @@ -232,8 +256,13 @@ export function ZoneProvider({ children }) { isAllZones, isTenantUser, tenantId, - isHubsLoading: isTenantUser ? isLocationsLoading : isHubsLoading, - refetchHubs: isTenantUser ? refetchLocations : refetchHubs, + isHubsLoading: isTenantUser ? isLocationsLoading || isHubsLoading : isHubsLoading, + refetchHubs: isTenantUser + ? () => { + refetchLocations(); + refetchHubs(); + } + : refetchHubs, matchesZone, }), [ diff --git a/src/lib/clientOnboarding.js b/src/lib/clientOnboarding.js index af60974..96d9b18 100644 --- a/src/lib/clientOnboarding.js +++ b/src/lib/clientOnboarding.js @@ -53,9 +53,75 @@ export function validateOnboarding(form) { errors.password = 'Must not be the email or the phone number'; } if (form.confirm !== pw) errors.confirm = 'The passwords do not match'; + Object.assign(errors, validateAddress(form)); return errors; } +/** + * The client's main address. Mirrors the server: an address, a 6-digit + * pincode, and map coordinates — which only come from picking a suggestion, + * so typed text alone is refused. + */ +export function validateAddress(form) { + const errors = {}; + if (String(form.address || '').trim().length < 5) errors.address = "Enter the client's address"; + else if (String(form.address).trim().length > 300) errors.address = 'At most 300 characters'; + else if (!hasMapLocation(form)) errors.address = 'Pick the address from the suggestions so it has a map location'; + if (!/^[1-9]\d{5}$/.test(String(form.pincode || '').replace(/\s/g, ''))) errors.pincode = 'Enter a 6-digit pincode'; + return errors; +} + +/** Real coordinates inside India (zero means nothing was picked). */ +export function hasMapLocation(form) { + const lat = Number(form.latitude); + const lng = Number(form.longitude); + return Number.isFinite(lat) && Number.isFinite(lng) && lat >= 6 && lat <= 37.5 && lng >= 68 && lng <= 97.5; +} + +/** + * The address fields from a place picked in AddressAutocomplete (the same + * shapes the order form reads: our own fields first, Google-style + * address_components as a fallback). + */ +export function addressFromPlace(place) { + if (!place) return {}; + const parts = { city: '', state: '', pincode: '' }; + (place.address_components || []).forEach((c) => { + (c.types || []).forEach((type) => { + if (type === 'locality') parts.city = c.long_name; + if (type === 'administrative_area_level_1') parts.state = c.long_name; + if (type === 'postal_code') parts.pincode = c.long_name; + }); + }); + const g = place.geometry?.location; + const read = (v) => (typeof v === 'function' ? v() : v); + const lat = place.latitude != null ? place.latitude : read(g?.lat); + const lng = place.longitude != null ? place.longitude : read(g?.lng); + const out = { + address: place.formatted_address || place.name || '', + city: place.city || parts.city, + state: place.state || parts.state, + pincode: String(place.postcode || parts.pincode || '').replace(/\s/g, ''), + }; + if (lat != null && lng != null) { + out.latitude = Number(lat); + out.longitude = Number(lng); + } + return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== '' && v != null)); +} + +/** The address part of a form, as the API takes it. */ +export function addressPayload(form) { + return { + address: String(form.address || '').trim(), + city: String(form.city || '').trim(), + state: String(form.state || '').trim(), + pincode: String(form.pincode || '').replace(/\s/g, ''), + latitude: Number(form.latitude), + longitude: Number(form.longitude), + }; +} + /** * Why onboarding cannot run, from the failed GET /admin/clients/cities. The * page shows this instead of an empty city list: the likeliest cause is a diff --git a/src/lib/consoleNav.js b/src/lib/consoleNav.js new file mode 100644 index 0000000..2faf2ac --- /dev/null +++ b/src/lib/consoleNav.js @@ -0,0 +1,37 @@ +/** + * Which header-menu groups a login sees. + * + * Kept apart from AdminLayout so the rule can be tested without rendering the + * whole shell. + */ + +/** + * The Fleet Ops pages a client (tenant) login may open. The server scopes each + * of these to the client: hubs to its own city, exceptions and returns to its + * own parcels. Everything else under Fleet Ops is internal or would show other + * clients' data, and the server refuses a client token on it. + */ +export const CLIENT_FLEET_OPS_PATHS = ['/doormile/hubs', '/doormile/exceptions', '/doormile/returns']; + +export const CLIENT_ONBOARDING_PATH = '/doormile/clients/onboard'; + +/** + * @param {Array<{label: string, items: Array<{path: string}>}>} groups the full menu + * @param {{isClient: boolean, canOnboard: boolean, onboardingItem: object}} who + */ +export function navGroupsFor(groups, { isClient, canOnboard, onboardingItem }) { + if (isClient) { + // Never Agents or Client Onboarding: those are Doormile-only. + return groups.map((group) => + group.label === 'Fleet Ops' + ? { ...group, items: group.items.filter((item) => CLIENT_FLEET_OPS_PATHS.includes(item.path)) } + : group + ); + } + if (!canOnboard) return groups; + // Only the onboarding owner login sees this entry; the server refuses + // everyone else regardless. + return groups.map((group) => + group.label === 'Fleet Ops' ? { ...group, items: [...group.items, onboardingItem] } : group + ); +} diff --git a/src/pages/doormile/clients/ClientLoginDialogs.jsx b/src/pages/doormile/clients/ClientLoginDialogs.jsx index d679786..ee2b69b 100644 --- a/src/pages/doormile/clients/ClientLoginDialogs.jsx +++ b/src/pages/doormile/clients/ClientLoginDialogs.jsx @@ -3,7 +3,8 @@ import { KeyRound, Pencil, Trash2 } from 'lucide-react'; import { Alert, Button, Field, Input, Modal, Switch } from '@/components/ds'; import { inputVariants } from '@/components/ui/input'; import { useDeleteOnboardedClient, useUpdateOnboardedClient } from '@/lib/doormileHooks'; -import { generateClientPassword } from '@/lib/clientOnboarding'; +import AddressAutocomplete from '@/components/doormile/AddressAutocomplete'; +import { addressFromPlace, addressPayload, generateClientPassword, validateAddress } from '@/lib/clientOnboarding'; /** * Edit and Remove for one onboarded client login (a row of "Clients with a @@ -24,6 +25,28 @@ export function changedFields(row, form) { if (form.status !== row.status) out.status = form.status; if (Boolean(form.requiredeliveryotp) !== Boolean(row.requiredeliveryotp)) out.requiredeliveryotp = Boolean(form.requiredeliveryotp); if (form.password) out.password = form.password; + // The address goes as one `location` object, and only when any part of it + // changed (a coordinate change alone counts: re-picking the same text moves + // the pin). + const addr = addressPayload(form); + const before = { + address: trim(row.address), + city: trim(row.city), + state: trim(row.state), + pincode: String(row.pincode || ''), + latitude: Number(row.latitude) || 0, + longitude: Number(row.longitude) || 0, + }; + if ( + addr.address !== before.address || + addr.pincode !== before.pincode || + addr.city !== before.city || + addr.state !== before.state || + (Number(addr.latitude) || 0) !== before.latitude || + (Number(addr.longitude) || 0) !== before.longitude + ) { + out.location = addr; + } return out; } @@ -35,6 +58,7 @@ export function validateEdit(form, changes) { if ('phone' in changes && !/^[6-9]\d{9}$/.test(changes.phone)) errors.phone = 'Enter a 10-digit mobile number'; if (form.password && form.password.length < 8) errors.password = 'At least 8 characters'; if (form.password && form.password.length > 72) errors.password = 'At most 72 characters'; + if ('location' in changes) Object.assign(errors, validateAddress(form)); return errors; } @@ -47,6 +71,12 @@ function formFrom(row) { status: STATUSES.includes(row?.status) ? row.status : 'Active', requiredeliveryotp: Boolean(row?.requiredeliveryotp), password: '', + address: row?.address || '', + city: row?.city || '', + state: row?.state || '', + pincode: row?.pincode || '', + latitude: row?.latitude || '', + longitude: row?.longitude || '', }; } @@ -172,6 +202,39 @@ export function EditClientModal({ row, onClose }) { )} +
+ set('address')(text)} + onPlaceSelected={(place) => { + setForm((f) => ({ ...f, ...addressFromPlace(place) })); + setErrors((e) => ({ ...e, address: undefined, pincode: undefined })); + }} + onPlaceCleared={() => setForm((f) => ({ ...f, latitude: '', longitude: '' }))} + /> + {errors.address ? ( +

+ {errors.address} +

+ ) : ( + !row?.address && ( +

No address on file yet. Add one so this client has a pickup point and zone.

+ ) + )} +
+ + set('pincode')(e.target.value.replace(/\D/g, '').slice(0, 6))} + /> + + + set('city')(e.target.value)} /> + diff --git a/src/pages/doormile/clients/ClientOnboarding.jsx b/src/pages/doormile/clients/ClientOnboarding.jsx index a96cb17..d90bc40 100644 --- a/src/pages/doormile/clients/ClientOnboarding.jsx +++ b/src/pages/doormile/clients/ClientOnboarding.jsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { ArrowLeft, Building2, Copy, Eye, EyeOff, KeyRound, Pencil, ShieldCheck, Trash2, UserPlus } from 'lucide-react'; +import { ArrowLeft, Building2, Copy, Eye, EyeOff, KeyRound, MapPin, Pencil, ShieldCheck, Trash2, UserPlus } from 'lucide-react'; import { Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, Button, DataTable, EmptyState, Field, Input, PageHeader, Stack, @@ -9,9 +9,11 @@ import { import { inputVariants } from '@/components/ui/input'; import { useAuth } from '@/lib/AuthContext'; import { useOnboardClient, useOnboardedClients, useOnboardingCities } from '@/lib/doormileHooks'; +import AddressAutocomplete from '@/components/doormile/AddressAutocomplete'; import { EditClientModal, RemoveClientModal } from './ClientLoginDialogs'; import { - canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateOnboarding, + addressFromPlace, addressPayload, canOnboardClients, generateClientPassword, hasMapLocation, + onboardingUnavailableMessage, validateOnboarding, } from '@/lib/clientOnboarding'; /** @@ -35,6 +37,14 @@ const EMPTY = { password: '', confirm: '', requiredeliveryotp: false, + // Main address, saved as the client's primary location. Coordinates come + // only from picking a suggestion. + address: '', + city: '', + state: '', + pincode: '', + latitude: '', + longitude: '', }; const SERVER_FIELD = [ @@ -44,6 +54,9 @@ const SERVER_FIELD = [ ['mobile', 'phone'], ['password', 'password'], ['operating city', 'applocationid'], + ['pincode', 'pincode'], + ['address', 'address'], + ['map location', 'address'], ]; /** Put a server message under the field it is about, when it names one. */ @@ -65,6 +78,11 @@ function OnboardedList() { { key: 'tenantname', header: 'Client', accessor: (r) => r.tenantname }, { key: 'loginemail', header: 'Console login', accessor: (r) => r.loginemail }, { key: 'primarycontact', header: 'Contact', accessor: (r) => r.primarycontact || '—' }, + { + key: 'address', + header: 'Address', + accessor: (r) => (r.address ? [r.address, r.pincode].filter(Boolean).join(' · ') : '—'), + }, { key: 'status', header: 'Status', cell: (r) => }, { key: 'logincreatedat', @@ -181,6 +199,7 @@ export default function ClientOnboarding() { password, applocationid: Number(form.applocationid), requiredeliveryotp: form.requiredeliveryotp, + ...addressPayload(form), }, { onSuccess: (res) => { @@ -286,7 +305,9 @@ export default function ClientOnboarding() { role="alert" title="Client onboarding is not available right now" action={ - } @@ -356,6 +377,47 @@ export default function ClientOnboarding() { +
+ set('address')(text)} + onPlaceSelected={(place) => { + setForm((f) => ({ ...f, ...addressFromPlace(place) })); + setErrors((e) => ({ ...e, address: undefined, pincode: undefined })); + }} + onPlaceCleared={() => setForm((f) => ({ ...f, latitude: '', longitude: '' }))} + /> + {errors.address ? ( +

+ {errors.address} +

+ ) : ( +

+ + {hasMapLocation(form) + ? "Location pinned. This becomes the client's pickup point and zone." + : 'Pick a suggestion so the address gets a map location.'} +

+ )} +
+ + + set('pincode')(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="641002" + inputMode="numeric" + /> + + + + set('city')(e.target.value)} placeholder="Coimbatore" maxLength={80} /> + + ), }, - ], - [hubMap] + ].filter(Boolean), + [hubMap, isClient] ); return ( {/* Early warnings from the console's monitoring skills (agent registry). */} - + {!isClient && } - Log exception - + isClient ? null : ( + + ) } /> @@ -307,7 +313,7 @@ export default function Exceptions() { icon={CheckCircle2} title="Nothing has gone wrong" description="No exceptions have been raised against a consignment." - action={{ label: 'Log exception', icon: Plus, onClick: openCreate }} + action={isClient ? undefined : { label: 'Log exception', icon: Plus, onClick: openCreate }} /> ) } diff --git a/src/pages/doormile/home/Home.jsx b/src/pages/doormile/home/Home.jsx index 493c6e6..7368b23 100644 --- a/src/pages/doormile/home/Home.jsx +++ b/src/pages/doormile/home/Home.jsx @@ -42,6 +42,7 @@ import { CREATE_CUSTOMER_TRIGGER, executeCreateCustomer } from '@/lib/assistant/ import { friendlyErrorMessage, MILETRUTH_HOME_CHATS_KEY } from '@/api/doormile/client'; import { answerSmallTalk, CAPABILITIES } from '@/lib/assistant/smalltalk'; import { buildRepeatRun, describeDay } from '@/lib/assistant/repeatRuns'; +import { useAuth } from '@/lib/AuthContext'; const MILETRUTH_SUGGESTIONS = [ "Repeat yesterday's orders", @@ -465,6 +466,9 @@ function HomeFlowDropdown({ step, draft, isBusy, onChoose, onCancel }) { export default function Home() { const navigate = useNavigate(); + // Tripsheets and Clients are Doormile-only pages; a client login gets no + // launcher tile for either. + const { isClient } = useAuth(); const queryClient = useQueryClient(); // State @@ -2298,17 +2302,19 @@ export default function Home() { Tripsheets, and the domain object is a tripsheet. A launcher tile whose label does not match its destination costs the operator a click to find that out, every time. */} - -
- -
- - Tripsheets - - + {!isClient && ( + +
+ +
+ + Tripsheets + + + )} @@ -2424,17 +2430,19 @@ export default function Home() { {/* Clients */} - -
- -
- - Clients - - + {!isClient && ( + +
+ +
+ + Clients + + + )} diff --git a/src/pages/doormile/hubs/Hubs.jsx b/src/pages/doormile/hubs/Hubs.jsx index 16c2c39..84e24c2 100644 --- a/src/pages/doormile/hubs/Hubs.jsx +++ b/src/pages/doormile/hubs/Hubs.jsx @@ -9,6 +9,7 @@ import { ListToolbar } from '@/components/doormile/ListToolbar'; import { validateHubForm } from '@/lib/hubForm'; import { useCreateHub, useDeleteHub, useHubs, useUpdateHub } from '@/lib/doormileHooks'; import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat'; +import { useAuth } from '@/lib/AuthContext'; /** * Hubs — the sorting centres and delivery hubs the network runs through. @@ -60,6 +61,9 @@ export default function Hubs() { const [errors, setErrors] = useState({}); const [pendingDelete, setPendingDelete] = useState(null); + // A client login sees its own city's hubs read-only; the server refuses a + // client's create, edit and delete. + const { isClient } = useAuth(); const createHub = useCreateHub(); const updateHub = useUpdateHub(); const deleteHub = useDeleteHub(); @@ -175,7 +179,7 @@ export default function Hubs() { sortable: true, cell: (row) => , }, - { + !isClient && { key: 'actions', header: 'Actions', align: 'right', @@ -192,8 +196,8 @@ export default function Hubs() { ), }, - ], - [] + ].filter(Boolean), + [isClient] ); return ( @@ -244,9 +248,11 @@ export default function Hubs() { placeholder="Search hubs, addresses or pincodes" count={{ shown: rows.length, total: hubs.length, noun: 'hubs' }} actions={ - + isClient ? null : ( + + ) } /> @@ -268,8 +274,12 @@ export default function Hubs() { ) } diff --git a/tests/integration/clientOnboarding.test.jsx b/tests/integration/clientOnboarding.test.jsx index 6d372b9..8546e13 100644 --- a/tests/integration/clientOnboarding.test.jsx +++ b/tests/integration/clientOnboarding.test.jsx @@ -1,5 +1,11 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { configure, render, screen, fireEvent, waitFor } from '@testing-library/react'; + +// The page renders the client list and the city query before anything can be +// found. Under the full suite (66 files at once) the first render here was +// regularly slower than the 1 s default, failing "shows Edit and Delete on +// each row" on timing alone. +configure({ asyncUtilTimeout: 5000 }); import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter } from 'react-router-dom'; @@ -35,9 +41,35 @@ jest.mock('@/api/doormile/notify', () => ({ let mockAuth = { user: { email: 'admin@doormile.com', role: 'admin', tenantid: null }, isClient: false }; jest.mock('@/lib/AuthContext', () => ({ useAuth: () => mockAuth })); +// The real address search calls the geocoding service. This stand-in is a text +// box plus a "Pick" button that selects a fixed place, the way picking a +// suggestion does. +const mockPickedPlace = { + formatted_address: '14 DB Road, RS Puram, Coimbatore', + latitude: 11.009, + longitude: 76.95, + city: 'Coimbatore', + postcode: '641002', + address_components: [{ types: ['administrative_area_level_1'], long_name: 'Tamil Nadu' }], +}; +jest.mock('@/components/doormile/AddressAutocomplete', () => ({ + __esModule: true, + default: ({ id, label, value, onChange, onPlaceSelected }) => ( +
+ + onChange(e.target.value)} /> + +
+ ), +})); + import * as api from '@/api/doormile'; import ClientOnboarding from '@/pages/doormile/clients/ClientOnboarding'; -import { canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateOnboarding } from '@/lib/clientOnboarding'; +import { + addressFromPlace, canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateAddress, validateOnboarding, +} from '@/lib/clientOnboarding'; const CITIES = [ { applocationid: 1, applocationname: 'Coimbatore', status: 'Active' }, @@ -67,6 +99,7 @@ const fillValidForm = async () => { fill('Operating city', '1'); fireEvent.change(document.getElementById('onboard-password'), { target: { value: 's3cure-pass' } }); fill('Confirm password', 's3cure-pass'); + fireEvent.click(screen.getAllByRole('button', { name: 'Pick Client address' })[0]); }; beforeEach(() => { @@ -99,6 +132,24 @@ describe('Edit and Delete on a client login', () => { await waitFor(() => expect(api.updateOnboardedClient).toHaveBeenCalledWith(31, { phone: '9123456780', status: 'Inactive' })); }); + it('adds an address for a client that has none, as one location object', async () => { + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' })); + await screen.findByRole('dialog'); + expect(screen.getByText(/No address on file yet/)).toBeInTheDocument(); + const picks = screen.getAllByRole('button', { name: 'Pick Client address' }); + fireEvent.click(picks[picks.length - 1]); // the dialog's address box + fireEvent.click(screen.getByRole('button', { name: /Save changes/ })); + await waitFor(() => + expect(api.updateOnboardedClient).toHaveBeenCalledWith(31, { + location: { + address: '14 DB Road, RS Puram, Coimbatore', city: 'Coimbatore', state: 'Tamil Nadu', + pincode: '641002', latitude: 11.009, longitude: 76.95, + }, + }) + ); + }); + it('resets the password only when one is entered, and checks its length', async () => { renderPage(); fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' })); @@ -171,6 +222,12 @@ describe('Client onboarding page', () => { password: 's3cure-pass', applocationid: 1, requiredeliveryotp: false, + address: '14 DB Road, RS Puram, Coimbatore', + city: 'Coimbatore', + state: 'Tamil Nadu', + pincode: '641002', + latitude: 11.009, + longitude: 76.95, }); expect(screen.getByRole('status')).toHaveTextContent('ops@acme.example'); expect(screen.getByRole('status')).toHaveTextContent('only time the password is shown'); @@ -198,6 +255,32 @@ describe('Client onboarding page', () => { expect(api.onboardClient).not.toHaveBeenCalled(); }); + it('needs an address with a map location before calling the server', async () => { + renderPage(); + await screen.findByRole('option', { name: 'Coimbatore' }); + fill('Company name', 'Acme Foods'); + fill('Contact person', 'Priya Raman'); + fill('Mobile number', '9876543210'); + fill('Login email', 'ops@acme.example'); + fill('Operating city', '1'); + fireEvent.change(document.getElementById('onboard-password'), { target: { value: 's3cure-pass' } }); + fill('Confirm password', 's3cure-pass'); + // Typed, never picked: no map location. + fireEvent.change(document.getElementById('onboard-address'), { target: { value: '14 DB Road, RS Puram' } }); + fill('Pincode', '641002'); + fireEvent.click(screen.getByRole('button', { name: /Onboard client/ })); + expect(await screen.findByText('Pick the address from the suggestions so it has a map location')).toBeInTheDocument(); + expect(api.onboardClient).not.toHaveBeenCalled(); + }); + + it('shows each client address in the list', async () => { + api.getOnboardedClients.mockResolvedValue([ + { authid: 32, tenantid: 13, tenantname: 'With Address', primarycontact: '9000000001', status: 'Active', loginemail: 'b@x.example', address: '5 Avinashi Road', pincode: '641004' }, + ]); + renderPage(); + expect(await screen.findByText('5 Avinashi Road · 641004')).toBeInTheDocument(); + }); + it('puts a server conflict under the field it is about', async () => { api.onboardClient.mockRejectedValue({ response: { status: 409, data: { success: false, message: 'this email already has a console login' } } }); renderPage(); @@ -277,6 +360,7 @@ describe('client onboarding helpers', () => { expect(pw).toMatch(/[a-z]/i); const errors = validateOnboarding({ companyname: 'Acme', contactname: 'Priya', email: 'a@b.co', phone: '9876543210', applocationid: 1, password: pw, confirm: pw, + address: '14 DB Road, RS Puram', pincode: '641002', latitude: 11.009, longitude: 76.95, }); expect(errors).toEqual({}); } @@ -296,4 +380,32 @@ describe('client onboarding helpers', () => { expect(validateOnboarding({ ...base, password: 'a@b.co', confirm: 'a@b.co' }).password).toBeTruthy(); expect(validateOnboarding({ ...base, password: 'short', confirm: 'short' }).password).toBeTruthy(); }); + + it('checks the address like the server does', () => { + const ok = { address: '14 DB Road, RS Puram', pincode: '641002', latitude: 11.009, longitude: 76.95 }; + expect(validateAddress(ok)).toEqual({}); + expect(validateAddress({ ...ok, address: 'abc' }).address).toMatch(/Enter the client/); + expect(validateAddress({ ...ok, pincode: '64100' }).pincode).toBeTruthy(); + expect(validateAddress({ ...ok, pincode: '041002' }).pincode).toBeTruthy(); + expect(validateAddress({ ...ok, latitude: '', longitude: '' }).address).toMatch(/Pick the address/); + expect(validateAddress({ ...ok, latitude: 76.95, longitude: 11.0 }).address).toMatch(/Pick the address/); // swapped + }); + + it('reads a picked place from either shape', () => { + expect(addressFromPlace({ formatted_address: 'X Road', latitude: 11, longitude: 77, city: 'Coimbatore', postcode: '641 002' })) + .toEqual({ address: 'X Road', city: 'Coimbatore', pincode: '641002', latitude: 11, longitude: 77 }); + const google = { + name: 'Y Street', + geometry: { location: { lat: () => 12.9, lng: () => 77.6 } }, + address_components: [ + { types: ['locality'], long_name: 'Bengaluru' }, + { types: ['administrative_area_level_1'], long_name: 'Karnataka' }, + { types: ['postal_code'], long_name: '560034' }, + ], + }; + expect(addressFromPlace(google)).toEqual({ + address: 'Y Street', city: 'Bengaluru', state: 'Karnataka', pincode: '560034', latitude: 12.9, longitude: 77.6, + }); + expect(addressFromPlace(null)).toEqual({}); + }); }); diff --git a/tests/lib/ZoneContext.test.jsx b/tests/lib/ZoneContext.test.jsx index 14c75a9..d7d4974 100644 --- a/tests/lib/ZoneContext.test.jsx +++ b/tests/lib/ZoneContext.test.jsx @@ -65,7 +65,7 @@ describe('ZoneContext', () => { it('should not load tenant locations for a staff login', () => { setup({ user: { tenantid: '0' } }); - expect(useHubs).toHaveBeenCalledWith({ enabled: true }); + expect(useHubs).toHaveBeenCalled(); expect(useTenantLocations).toHaveBeenCalledWith(null, { enabled: false }); }); }); @@ -79,17 +79,40 @@ describe('ZoneContext', () => { longitude: 77.6408 }; - it('should offer the tenant kitchen hubs instead of global hubs', () => { + // A client sees their own locations first, then the Doormile hubs. A newly + // onboarded client used to have no locations and therefore no zones at all + // — not even the Coimbatore hub their orders run through. + it('should offer the tenant own locations and then the Doormile hubs', () => { const { result } = setup({ user: { tenantid: '4' }, locations: [LOCATION] }); expect(result.current.isTenantUser).toBe(true); - expect(result.current.zones.map((z) => z.hubname)).toEqual(['All Zones', 'Indiranagar Kitchen']); + expect(result.current.zones.map((z) => z.hubname)).toEqual(['All Zones', 'Indiranagar Kitchen', 'Koramangala Hub']); expect(result.current.zones[1].hubtype).toBe('kitchen_hub'); + expect(result.current.zones[1].isTenantLocation).toBe(true); + expect(result.current.zones[2].isTenantLocation).toBeFalsy(); + }); + + it('should prefix a location zone id so it cannot collide with a hub id', () => { + const { result } = setup({ user: { tenantid: '4' }, locations: [{ ...LOCATION, tenantlocationid: 2 }] }); + expect(result.current.zones.map((z) => z.hubid)).toEqual(['all', 'loc:2', '2']); + expect(result.current.zones[1].locationid).toBe('2'); + }); + + it('should offer only the hubs of the client own city when the login carries it', () => { + const CBE = { ...KORAMANGALA, hubid: '7', hubname: 'Coimbatore Neptune Hub', applocationid: 1 }; + const BLR = { ...KORAMANGALA, applocationid: 3 }; + const { result } = setup({ user: { tenantid: '4', applocationid: 1 }, hubs: [CBE, BLR] }); + expect(result.current.zones.map((z) => z.hubname)).toEqual(['All Zones', 'Coimbatore Neptune Hub']); + }); + + it('should still show the hubs to a client with no locations yet', () => { + const { result } = setup({ user: { tenantid: '4' }, locations: [] }); + expect(result.current.zones.map((z) => z.hubname)).toEqual(['All Zones', 'Koramangala Hub']); }); it('should scope the location query to the operator own tenant', () => { setup({ user: { tenantid: '4' }, locations: [LOCATION] }); expect(useTenantLocations).toHaveBeenCalledWith('4', { enabled: true }); - expect(useHubs).toHaveBeenCalledWith({ enabled: false }); + expect(useHubs).toHaveBeenCalled(); }); it('should treat tenantid "0" as staff, not as a tenant', () => { @@ -425,7 +448,7 @@ describe('ZoneContext', () => { expect(result.current.isHubsLoading).toBe(true); }); - it('should refetch the source the current login actually reads', () => { + it('should refetch both sources a client login reads', () => { const refetchHubs = jest.fn(); const refetchLocations = jest.fn(); useAuth.mockReturnValue({ user: { tenantid: '4' } }); @@ -436,7 +459,7 @@ describe('ZoneContext', () => { act(() => result.current.refetchHubs()); expect(refetchLocations).toHaveBeenCalledTimes(1); - expect(refetchHubs).not.toHaveBeenCalled(); + expect(refetchHubs).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/lib/consoleNav.test.js b/tests/lib/consoleNav.test.js new file mode 100644 index 0000000..131700b --- /dev/null +++ b/tests/lib/consoleNav.test.js @@ -0,0 +1,51 @@ +import { CLIENT_FLEET_OPS_PATHS, CLIENT_ONBOARDING_PATH, navGroupsFor } from '@/lib/consoleNav'; +import { canOnboardClients } from '@/lib/clientOnboarding'; + +const GROUPS = [ + { + label: 'Fleet Ops', + items: [ + { label: 'Hubs', path: '/doormile/hubs' }, + { label: 'Vehicles', path: '/doormile/vehicles' }, + { label: 'Tripsheets', path: '/doormile/tripsheets' }, + { label: 'Exceptions', path: '/doormile/exceptions' }, + { label: 'Returns', path: '/doormile/returns' }, + { label: 'Competitive Intel', path: '/doormile/competitive-intel' }, + { label: 'App Users', path: '/doormile/app-users' }, + { label: 'Agents', path: '/doormile/agents' }, + ], + }, + { label: 'Reports', items: [{ label: 'Orders Summary', path: '/doormile/reports/orderssummary' }] }, +]; +const ONBOARD = { label: 'Client Onboarding', path: CLIENT_ONBOARDING_PATH }; + +const labelsOf = (groups, name) => groups.find((g) => g.label === name).items.map((i) => i.label); + +describe('header menu per login', () => { + test('a client sees Fleet Ops with only Hubs, Exceptions and Returns', () => { + const groups = navGroupsFor(GROUPS, { isClient: true, canOnboard: false, onboardingItem: ONBOARD }); + expect(labelsOf(groups, 'Fleet Ops')).toEqual(['Hubs', 'Exceptions', 'Returns']); + expect(labelsOf(groups, 'Reports')).toEqual(['Orders Summary']); + }); + + test('a client never sees Agents or Client Onboarding, even if asked to', () => { + const groups = navGroupsFor(GROUPS, { isClient: true, canOnboard: true, onboardingItem: ONBOARD }); + const all = groups.flatMap((g) => g.items.map((i) => i.path)); + expect(all).not.toContain('/doormile/agents'); + expect(all).not.toContain(CLIENT_ONBOARDING_PATH); + expect(all.filter((p) => p.startsWith('/doormile/') && !p.includes('/reports/'))).toEqual(CLIENT_FLEET_OPS_PATHS); + }); + + test('the onboarding owner sees the full Fleet Ops menu plus Client Onboarding', () => { + const owner = { email: 'admin@doormile.com', role: 'admin' }; + const groups = navGroupsFor(GROUPS, { isClient: false, canOnboard: canOnboardClients(owner, false), onboardingItem: ONBOARD }); + expect(labelsOf(groups, 'Fleet Ops')).toEqual([...GROUPS[0].items.map((i) => i.label), 'Client Onboarding']); + }); + + test('other Doormile staff see Fleet Ops and Agents but not Client Onboarding', () => { + const staff = { email: 'ops@doormile.com', role: 'admin' }; + const groups = navGroupsFor(GROUPS, { isClient: false, canOnboard: canOnboardClients(staff, false), onboardingItem: ONBOARD }); + expect(labelsOf(groups, 'Fleet Ops')).toContain('Agents'); + expect(labelsOf(groups, 'Fleet Ops')).not.toContain('Client Onboarding'); + }); +}); diff --git a/tests/lib/zoneHubIds.test.jsx b/tests/lib/zoneHubIds.test.jsx index 15cb9f0..d988996 100644 --- a/tests/lib/zoneHubIds.test.jsx +++ b/tests/lib/zoneHubIds.test.jsx @@ -87,8 +87,10 @@ describe('a tenant session never matches on a hub id', () => { it('a booking whose pickuphubid is 5 does not land in tenant location 5', () => { // Two different id spaces. Matching them would file an order under a // kitchen it has nothing to do with, on a bare numeric collision. + // A client location's zone id is prefixed ('loc:5') precisely so it can + // never be confused with hub 5, which a client now also sees. const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] }); - expect(inZone(result, '5', { + expect(inZone(result, 'loc:5', { bookingid: 3, bookingno: 'DM-X', tenantid: '13', pickuphubid: 5, pickupaddress: 'Gate 3', deliveryaddress: 'Block C' })).toBe(false); @@ -96,11 +98,28 @@ describe('a tenant session never matches on a hub id', () => { it('but its own tenantlocationid still matches', () => { const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] }); - expect(inZone(result, '5', { + expect(inZone(result, 'loc:5', { bookingid: 4, bookingno: 'DM-Y', tenantid: '13', tenantlocationid: 5, pickupaddress: 'Gate 3', deliveryaddress: 'Block C' })).toBe(true); }); + + it('a client who picks a Doormile HUB is matched on hub ids, never on location ids', () => { + const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] }); + expect(inZone(result, '5', { + bookingid: 5, bookingno: 'DM-H', tenantid: '13', pickuphubid: 5, + pickupaddress: 'Gate 3', deliveryaddress: 'Block C' + })).toBe(true); + expect(inZone(result, '5', { + bookingid: 6, bookingno: 'DM-L', tenantid: '13', tenantlocationid: 5, + pickupaddress: 'Gate 3', deliveryaddress: 'Block C' + })).toBe(false); + }); + + it("never shows a client another tenant's order, whatever zone is picked", () => { + const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] }); + expect(inZone(result, '5', { bookingid: 7, tenantid: '99', pickuphubid: 5, pickupaddress: 'a' })).toBe(false); + }); }); describe('the city comparison, which needs a city on the hub', () => { @@ -172,9 +191,18 @@ describe('one id space at a time', () => { const KITCHEN = { tenantlocationid: 5, locationname: 'Indiranagar Kitchen', city: 'Bengaluru', latitude: 12.9784, longitude: 77.6408, pincode: '560038' }; const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] }); - expect(inZone(result, '5', { bookingid: 1, tenantid: '13', tenantlocationid: 5, + expect(inZone(result, 'loc:5', { bookingid: 1, tenantid: '13', tenantlocationid: 5, pickupaddress: 'a', deliveryaddress: 'b' })).toBe(true); - expect(inZone(result, '5', { bookingid: 2, tenantid: '13', servicinghubid: 5, + expect(inZone(result, 'loc:5', { bookingid: 2, tenantid: '13', servicinghubid: 5, pickupaddress: 'a', deliveryaddress: 'b' })).toBe(false); }); + + it('a deactivated tenant location is not offered as a zone', () => { + const LIVE = { tenantlocationid: 7, locationname: 'Saibaba Colony', status: 'Active' }; + const OLD = { tenantlocationid: 8, locationname: 'Main Branch', status: 'Inactive' }; + const { result } = setup({ user: { tenantid: '13' }, locations: [LIVE, OLD] }); + const ids = result.current.zones.map((z) => z.hubid); + expect(ids).toContain('loc:7'); + expect(ids).not.toContain('loc:8'); + }); });