updates on the admin and zone things and minor changes

This commit is contained in:
2026-10-06 19:21:06 +05:30
parent 6e73b59ca9
commit 7aca6a6a48
14 changed files with 589 additions and 90 deletions

View File

@@ -14,6 +14,13 @@ import Login from '@/pages/Login';
/* Every console page is code-split. The Dispatch board alone pulls the map, /* 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 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. */ 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 ? <Navigate to="/doormile/home" replace /> : children;
}
const Dispatch = lazy(() => import('@/pages/doormile/dispatch/Dispatch.jsx')); const Dispatch = lazy(() => import('@/pages/doormile/dispatch/Dispatch.jsx'));
const DispatchPreview = lazy(() => import('@/pages/doormile/dispatch/Preview.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). */} {/* Reverse logistics: parcels going back to the sender (RTO). */}
<Route path="returns" element={<Returns />} /> <Route path="returns" element={<Returns />} />
<Route path="tenants" element={<Tenants />} /> <Route path="tenants" element={<StaffOnly><Tenants /></StaffOnly>} />
<Route path="clients/create" element={<CreateClient />} /> <Route path="clients/create" element={<StaffOnly><CreateClient /></StaffOnly>} />
{/* Owner-only (admin@doormile.com); the page and the server both check. */} {/* Owner-only (admin@doormile.com); the page and the server both check. */}
<Route path="clients/onboard" element={<ClientOnboarding />} /> <Route path="clients/onboard" element={<StaffOnly><ClientOnboarding /></StaffOnly>} />
<Route path="customer/create" element={<CreateCustomer />} /> <Route path="customer/create" element={<CreateCustomer />} />
<Route path="pricing" element={<Pricing />} /> <Route path="pricing" element={<Pricing />} />
@@ -145,11 +152,11 @@ const AuthenticatedApp = () => {
<Route path="customers" element={<Customers />} /> <Route path="customers" element={<Customers />} />
<Route path="hubs" element={<Hubs />} /> <Route path="hubs" element={<Hubs />} />
<Route path="vehicles" element={<Vehicles />} /> <Route path="vehicles" element={<StaffOnly><Vehicles /></StaffOnly>} />
<Route path="app-users" element={<AppUsers />} /> <Route path="app-users" element={<StaffOnly><AppUsers /></StaffOnly>} />
<Route path="tripsheets" element={<Tripsheets />} /> <Route path="tripsheets" element={<StaffOnly><Tripsheets /></StaffOnly>} />
<Route path="exceptions" element={<Exceptions />} /> <Route path="exceptions" element={<Exceptions />} />
<Route path="competitive-intel" element={<CompetitiveIntel />} /> <Route path="competitive-intel" element={<StaffOnly><CompetitiveIntel /></StaffOnly>} />
<Route path="reports/orderssummary" element={<OrdersSummary />} /> <Route path="reports/orderssummary" element={<OrdersSummary />} />
<Route path="reports/ordersdetails" element={<OrdersDetails />} /> <Route path="reports/ordersdetails" element={<OrdersDetails />} />
@@ -162,7 +169,7 @@ const AuthenticatedApp = () => {
{/* The AI_engine swarm. Read-only: it reports what the agents are {/* The AI_engine swarm. Read-only: it reports what the agents are
and what they last did, and has no control surface, because and what they last did, and has no control surface, because
every autonomy gate lives in that repository's own env. */} every autonomy gate lives in that repository's own env. */}
<Route path="agents" element={<Agents />} /> <Route path="agents" element={<StaffOnly><Agents /></StaffOnly>} />
</Route> </Route>
</Route> </Route>

View File

@@ -17,6 +17,7 @@ import {
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useAuth } from '@/lib/AuthContext'; import { useAuth } from '@/lib/AuthContext';
import { canOnboardClients } from '@/lib/clientOnboarding'; import { canOnboardClients } from '@/lib/clientOnboarding';
import { CLIENT_ONBOARDING_PATH, navGroupsFor } from '@/lib/consoleNav';
import { useExceptions } from '@/lib/doormileHooks'; import { useExceptions } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp'; import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import AIPanel from '@/components/assistant/DoormileAI/AIPanel'; import AIPanel from '@/components/assistant/DoormileAI/AIPanel';
@@ -260,19 +261,15 @@ export default function AdminLayout() {
const canOnboard = canOnboardClients(user, isClient); const canOnboard = canOnboardClients(user, isClient);
const navGroups = useMemo(() => { const navGroups = useMemo(
if (isClient) { () =>
return NAV_GROUPS.filter((group) => group.label !== 'Fleet Ops'); navGroupsFor(NAV_GROUPS, {
} isClient,
if (!canOnboard) return NAV_GROUPS; canOnboard,
// Only the onboarding owner login sees this entry; the server refuses onboardingItem: { label: 'Client Onboarding', path: CLIENT_ONBOARDING_PATH, icon: UserPlus },
// everyone else regardless. }),
return NAV_GROUPS.map((group) => [isClient, canOnboard]
group.label === 'Fleet Ops' );
? { ...group, items: [...group.items, { label: 'Client Onboarding', path: '/doormile/clients/onboard', icon: UserPlus }] }
: group
);
}, [isClient, canOnboard]);
const allDestinations = useMemo(() => { const allDestinations = useMemo(() => {
return [...navItems, ...navGroups.flatMap((group) => group.items)]; return [...navItems, ...navGroups.flatMap((group) => group.items)];

View File

@@ -23,10 +23,16 @@ export function ZoneProvider({ children }) {
const tenantId = user?.tenantid || localStorage.getItem('tenantid') || ''; const tenantId = user?.tenantid || localStorage.getItem('tenantid') || '';
const isTenantUser = Boolean(tenantId && tenantId !== '0'); const isTenantUser = Boolean(tenantId && tenantId !== '0');
// Staff loads global hubs; Tenant loads tenant locations / kitchen hubs // A client login's city (from the login response), used to offer them the
const { data: rawHubs = [], isLoading: isHubsLoading, refetch: refetchHubs } = useHubs({ // Doormile hubs of their own city. Missing on a session signed in before the
enabled: !isTenantUser, // 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( const { data: tenantLocations = [], isLoading: isLocationsLoading, refetch: refetchLocations } = useTenantLocations(
isTenantUser ? tenantId : null, isTenantUser ? tenantId : null,
{ enabled: isTenantUser } { enabled: isTenantUser }
@@ -41,10 +47,21 @@ export function ZoneProvider({ children }) {
}); });
const activeHubs = useMemo(() => { const activeHubs = useMemo(() => {
const doormileHubs = (rawHubs || []).filter(
(hub) => String(hub.status || 'Active').toLowerCase() === 'active'
);
if (isTenantUser) { if (isTenantUser) {
return (tenantLocations || []).map((loc) => ({ // A client location's id and a hub id are separate numberings, so the
hubid: String(loc.tenantlocationid || loc.locationid || loc.id), // location's zone id is prefixed; `locationid` keeps the raw one for
id: String(loc.tenantlocationid || loc.locationid || loc.id), // 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}`, hubname: loc.locationname || loc.name || `Kitchen Hub #${loc.tenantlocationid || loc.locationid}`,
city: loc.city || '', city: loc.city || '',
address: loc.address || '', address: loc.address || '',
@@ -55,12 +72,14 @@ export function ZoneProvider({ children }) {
isTenantLocation: true, isTenantLocation: true,
status: 'Active', status: 'Active',
})); }));
const cityHubs = clientCityId
? doormileHubs.filter((hub) => String(hub.applocationid ?? '') === clientCityId)
: doormileHubs;
return [...own, ...cityHubs];
} }
return (rawHubs || []).filter( return doormileHubs;
(hub) => String(hub.status || 'Active').toLowerCase() === 'active' }, [isTenantUser, tenantLocations, rawHubs, clientCityId]);
);
}, [isTenantUser, tenantLocations, rawHubs]);
const zones = useMemo(() => { const zones = useMemo(() => {
return [ALL_ZONE, ...activeHubs]; 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 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 // the collision only fired when the earlier ones happened to be absent. It
// was a bug waiting on its inputs, not a safe design. // 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.tenantlocationid, item.locationid]
: [item.servicinghubid, item.pickuphubid, item.nearesthubid, item.hubid, item.sourcehubid]; : [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; return true;
} }
@@ -232,8 +256,13 @@ export function ZoneProvider({ children }) {
isAllZones, isAllZones,
isTenantUser, isTenantUser,
tenantId, tenantId,
isHubsLoading: isTenantUser ? isLocationsLoading : isHubsLoading, isHubsLoading: isTenantUser ? isLocationsLoading || isHubsLoading : isHubsLoading,
refetchHubs: isTenantUser ? refetchLocations : refetchHubs, refetchHubs: isTenantUser
? () => {
refetchLocations();
refetchHubs();
}
: refetchHubs,
matchesZone, matchesZone,
}), }),
[ [

View File

@@ -53,9 +53,75 @@ export function validateOnboarding(form) {
errors.password = 'Must not be the email or the phone number'; errors.password = 'Must not be the email or the phone number';
} }
if (form.confirm !== pw) errors.confirm = 'The passwords do not match'; if (form.confirm !== pw) errors.confirm = 'The passwords do not match';
Object.assign(errors, validateAddress(form));
return errors; 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 * 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 * page shows this instead of an empty city list: the likeliest cause is a

37
src/lib/consoleNav.js Normal file
View File

@@ -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
);
}

View File

@@ -3,7 +3,8 @@ import { KeyRound, Pencil, Trash2 } from 'lucide-react';
import { Alert, Button, Field, Input, Modal, Switch } from '@/components/ds'; import { Alert, Button, Field, Input, Modal, Switch } from '@/components/ds';
import { inputVariants } from '@/components/ui/input'; import { inputVariants } from '@/components/ui/input';
import { useDeleteOnboardedClient, useUpdateOnboardedClient } from '@/lib/doormileHooks'; 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 * 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 (form.status !== row.status) out.status = form.status;
if (Boolean(form.requiredeliveryotp) !== Boolean(row.requiredeliveryotp)) out.requiredeliveryotp = Boolean(form.requiredeliveryotp); if (Boolean(form.requiredeliveryotp) !== Boolean(row.requiredeliveryotp)) out.requiredeliveryotp = Boolean(form.requiredeliveryotp);
if (form.password) out.password = form.password; 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; 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 ('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 < 8) errors.password = 'At least 8 characters';
if (form.password && form.password.length > 72) errors.password = 'At most 72 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; return errors;
} }
@@ -47,6 +71,12 @@ function formFrom(row) {
status: STATUSES.includes(row?.status) ? row.status : 'Active', status: STATUSES.includes(row?.status) ? row.status : 'Active',
requiredeliveryotp: Boolean(row?.requiredeliveryotp), requiredeliveryotp: Boolean(row?.requiredeliveryotp),
password: '', 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 }) {
</Button> </Button>
)} )}
</div> </div>
<div className="sm:col-span-2">
<AddressAutocomplete
id="edit-client-address"
label="Client address"
placeholder="Search the client's building, street or area…"
value={form.address}
onChange={(text) => 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 ? (
<p className="mt-1 text-caption text-destructive" role="alert">
{errors.address}
</p>
) : (
!row?.address && (
<p className="mt-1 text-caption text-ink-3">No address on file yet. Add one so this client has a pickup point and zone.</p>
)
)}
</div>
<Field label="Pincode" error={errors.pincode}>
<Input
value={form.pincode}
inputMode="numeric"
onChange={(e) => set('pincode')(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</Field>
<Field label="City">
<Input value={form.city} maxLength={80} onChange={(e) => set('city')(e.target.value)} />
</Field>
<Field label="Require delivery OTP" inline className="sm:col-span-2"> <Field label="Require delivery OTP" inline className="sm:col-span-2">
<Switch checked={form.requiredeliveryotp} onCheckedChange={set('requiredeliveryotp')} /> <Switch checked={form.requiredeliveryotp} onCheckedChange={set('requiredeliveryotp')} />
</Field> </Field>

View File

@@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; 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 { import {
Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
BreadcrumbSeparator, Button, DataTable, EmptyState, Field, Input, PageHeader, Stack, BreadcrumbSeparator, Button, DataTable, EmptyState, Field, Input, PageHeader, Stack,
@@ -9,9 +9,11 @@ import {
import { inputVariants } from '@/components/ui/input'; import { inputVariants } from '@/components/ui/input';
import { useAuth } from '@/lib/AuthContext'; import { useAuth } from '@/lib/AuthContext';
import { useOnboardClient, useOnboardedClients, useOnboardingCities } from '@/lib/doormileHooks'; import { useOnboardClient, useOnboardedClients, useOnboardingCities } from '@/lib/doormileHooks';
import AddressAutocomplete from '@/components/doormile/AddressAutocomplete';
import { EditClientModal, RemoveClientModal } from './ClientLoginDialogs'; import { EditClientModal, RemoveClientModal } from './ClientLoginDialogs';
import { import {
canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateOnboarding, addressFromPlace, addressPayload, canOnboardClients, generateClientPassword, hasMapLocation,
onboardingUnavailableMessage, validateOnboarding,
} from '@/lib/clientOnboarding'; } from '@/lib/clientOnboarding';
/** /**
@@ -35,6 +37,14 @@ const EMPTY = {
password: '', password: '',
confirm: '', confirm: '',
requiredeliveryotp: false, 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 = [ const SERVER_FIELD = [
@@ -44,6 +54,9 @@ const SERVER_FIELD = [
['mobile', 'phone'], ['mobile', 'phone'],
['password', 'password'], ['password', 'password'],
['operating city', 'applocationid'], ['operating city', 'applocationid'],
['pincode', 'pincode'],
['address', 'address'],
['map location', 'address'],
]; ];
/** Put a server message under the field it is about, when it names one. */ /** 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: 'tenantname', header: 'Client', accessor: (r) => r.tenantname },
{ key: 'loginemail', header: 'Console login', accessor: (r) => r.loginemail }, { key: 'loginemail', header: 'Console login', accessor: (r) => r.loginemail },
{ key: 'primarycontact', header: 'Contact', accessor: (r) => r.primarycontact || '—' }, { 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) => <StatusBadge status={r.status} size="sm" /> }, { key: 'status', header: 'Status', cell: (r) => <StatusBadge status={r.status} size="sm" /> },
{ {
key: 'logincreatedat', key: 'logincreatedat',
@@ -181,6 +199,7 @@ export default function ClientOnboarding() {
password, password,
applocationid: Number(form.applocationid), applocationid: Number(form.applocationid),
requiredeliveryotp: form.requiredeliveryotp, requiredeliveryotp: form.requiredeliveryotp,
...addressPayload(form),
}, },
{ {
onSuccess: (res) => { onSuccess: (res) => {
@@ -286,7 +305,9 @@ export default function ClientOnboarding() {
role="alert" role="alert"
title="Client onboarding is not available right now" title="Client onboarding is not available right now"
action={ action={
<Button size="sm" variant="outline" onClick={() => citiesQ.refetch()}> // type="button": this sits inside the form, and a plain button
// would also submit it, flashing every field's error.
<Button type="button" size="sm" variant="outline" onClick={() => citiesQ.refetch()}>
Try again Try again
</Button> </Button>
} }
@@ -356,6 +377,47 @@ export default function ClientOnboarding() {
</select> </select>
</Field> </Field>
<div className="sm:col-span-2">
<AddressAutocomplete
id="onboard-address"
label="Client address"
required
placeholder="Search the client's building, street or area…"
value={form.address}
onChange={(text) => 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 ? (
<p className="mt-1 text-caption text-destructive" role="alert">
{errors.address}
</p>
) : (
<p className="mt-1 flex items-center gap-1 text-caption text-ink-3">
<MapPin className="h-3.5 w-3.5" />
{hasMapLocation(form)
? "Location pinned. This becomes the client's pickup point and zone."
: 'Pick a suggestion so the address gets a map location.'}
</p>
)}
</div>
<Field label="Pincode" required error={errors.pincode}>
<Input
value={form.pincode}
onChange={(e) => set('pincode')(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="641002"
inputMode="numeric"
/>
</Field>
<Field label="City">
<Input value={form.city} onChange={(e) => set('city')(e.target.value)} placeholder="Coimbatore" maxLength={80} />
</Field>
<Field label="Password" required error={errors.password} hint="At least 8 characters" htmlFor="onboard-password"> <Field label="Password" required error={errors.password} hint="At least 8 characters" htmlFor="onboard-password">
<Input <Input
id="onboard-password" id="onboard-password"

View File

@@ -11,6 +11,7 @@ import { StartRtoModal } from '@/components/doormile/RtoDialogs';
import { useCreateException, useExceptions, useHubs, useUpdateExceptionStatus } from '@/lib/doormileHooks'; import { useCreateException, useExceptions, useHubs, useUpdateExceptionStatus } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp'; import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat'; import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
import { useAuth } from '@/lib/AuthContext';
/** /**
* Exceptions — the register of things that went wrong with a consignment. * Exceptions — the register of things that went wrong with a consignment.
@@ -62,6 +63,9 @@ export default function Exceptions() {
const [resolveStatus, setResolveStatus] = useState('Resolved'); const [resolveStatus, setResolveStatus] = useState('Resolved');
const [resolution, setResolution] = useState(''); const [resolution, setResolution] = useState('');
// A client login reads exceptions on its own parcels; logging, resolving and
// starting a return are Doormile actions the server refuses a client.
const { isClient } = useAuth();
const createException = useCreateException(); const createException = useCreateException();
const updateStatus = useUpdateExceptionStatus(); const updateStatus = useUpdateExceptionStatus();
@@ -197,7 +201,7 @@ export default function Exceptions() {
), ),
hideBelow: 'lg', hideBelow: 'lg',
}, },
{ !isClient && {
key: 'actions', key: 'actions',
header: 'Actions', header: 'Actions',
align: 'right', align: 'right',
@@ -227,14 +231,14 @@ export default function Exceptions() {
</div> </div>
), ),
}, },
], ].filter(Boolean),
[hubMap] [hubMap, isClient]
); );
return ( return (
<Stack space="lg"> <Stack space="lg">
{/* Early warnings from the console's monitoring skills (agent registry). */} {/* Early warnings from the console's monitoring skills (agent registry). */}
<AgentOperationsBanner /> {!isClient && <AgentOperationsBanner />}
<Grid cols={4}> <Grid cols={4}>
<KpiCard <KpiCard
@@ -282,9 +286,11 @@ export default function Exceptions() {
placeholder="Search by consignment, type or description" placeholder="Search by consignment, type or description"
count={{ shown: rows.length, total: exceptions.length, noun: 'exceptions' }} count={{ shown: rows.length, total: exceptions.length, noun: 'exceptions' }}
actions={ actions={
<Button onClick={openCreate}> isClient ? null : (
<Plus className="mr-1.5 h-4 w-4" /> Log exception <Button onClick={openCreate}>
</Button> <Plus className="mr-1.5 h-4 w-4" /> Log exception
</Button>
)
} }
/> />
@@ -307,7 +313,7 @@ export default function Exceptions() {
icon={CheckCircle2} icon={CheckCircle2}
title="Nothing has gone wrong" title="Nothing has gone wrong"
description="No exceptions have been raised against a consignment." 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 }}
/> />
) )
} }

View File

@@ -42,6 +42,7 @@ import { CREATE_CUSTOMER_TRIGGER, executeCreateCustomer } from '@/lib/assistant/
import { friendlyErrorMessage, MILETRUTH_HOME_CHATS_KEY } from '@/api/doormile/client'; import { friendlyErrorMessage, MILETRUTH_HOME_CHATS_KEY } from '@/api/doormile/client';
import { answerSmallTalk, CAPABILITIES } from '@/lib/assistant/smalltalk'; import { answerSmallTalk, CAPABILITIES } from '@/lib/assistant/smalltalk';
import { buildRepeatRun, describeDay } from '@/lib/assistant/repeatRuns'; import { buildRepeatRun, describeDay } from '@/lib/assistant/repeatRuns';
import { useAuth } from '@/lib/AuthContext';
const MILETRUTH_SUGGESTIONS = [ const MILETRUTH_SUGGESTIONS = [
"Repeat yesterday's orders", "Repeat yesterday's orders",
@@ -465,6 +466,9 @@ function HomeFlowDropdown({ step, draft, isBusy, onChoose, onCancel }) {
export default function Home() { export default function Home() {
const navigate = useNavigate(); 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(); const queryClient = useQueryClient();
// State // State
@@ -2298,17 +2302,19 @@ export default function Home() {
Tripsheets, and the domain object is a tripsheet. A launcher Tripsheets, and the domain object is a tripsheet. A launcher
tile whose label does not match its destination costs the tile whose label does not match its destination costs the
operator a click to find that out, every time. */} operator a click to find that out, every time. */}
<Link {!isClient && (
to="/doormile/tripsheets" <Link
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer" to="/doormile/tripsheets"
> className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
<div className="w-12 h-12 rounded-2xl bg-purple-50 border border-purple-200/90 flex items-center justify-center text-purple-500 group-hover:scale-105 group-hover:bg-purple-100 group-hover:shadow-sm transition-all duration-200"> >
<ListTodo className="w-5 h-5" /> <div className="w-12 h-12 rounded-2xl bg-purple-50 border border-purple-200/90 flex items-center justify-center text-purple-500 group-hover:scale-105 group-hover:bg-purple-100 group-hover:shadow-sm transition-all duration-200">
</div> <ListTodo className="w-5 h-5" />
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-1.5 text-center leading-tight line-clamp-2 break-words w-full transition-colors"> </div>
Tripsheets <span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-1.5 text-center leading-tight line-clamp-2 break-words w-full transition-colors">
</span> Tripsheets
</Link> </span>
</Link>
)}
</div> </div>
</div> </div>
@@ -2424,17 +2430,19 @@ export default function Home() {
</Link> </Link>
{/* Clients */} {/* Clients */}
<Link {!isClient && (
to="/doormile/tenants" <Link
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer" to="/doormile/tenants"
> className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
<div className="w-12 h-12 rounded-2xl bg-purple-50 border border-purple-200/90 flex items-center justify-center text-purple-500 group-hover:scale-105 group-hover:bg-purple-100 group-hover:shadow-sm transition-all duration-200"> >
<Building2 className="w-5 h-5" /> <div className="w-12 h-12 rounded-2xl bg-purple-50 border border-purple-200/90 flex items-center justify-center text-purple-500 group-hover:scale-105 group-hover:bg-purple-100 group-hover:shadow-sm transition-all duration-200">
</div> <Building2 className="w-5 h-5" />
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-1.5 text-center leading-tight line-clamp-2 break-words w-full transition-colors"> </div>
Clients <span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-1.5 text-center leading-tight line-clamp-2 break-words w-full transition-colors">
</span> Clients
</Link> </span>
</Link>
)}
</div> </div>
</div> </div>

View File

@@ -9,6 +9,7 @@ import { ListToolbar } from '@/components/doormile/ListToolbar';
import { validateHubForm } from '@/lib/hubForm'; import { validateHubForm } from '@/lib/hubForm';
import { useCreateHub, useDeleteHub, useHubs, useUpdateHub } from '@/lib/doormileHooks'; import { useCreateHub, useDeleteHub, useHubs, useUpdateHub } from '@/lib/doormileHooks';
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat'; import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
import { useAuth } from '@/lib/AuthContext';
/** /**
* Hubs — the sorting centres and delivery hubs the network runs through. * Hubs — the sorting centres and delivery hubs the network runs through.
@@ -60,6 +61,9 @@ export default function Hubs() {
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [pendingDelete, setPendingDelete] = useState(null); 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 createHub = useCreateHub();
const updateHub = useUpdateHub(); const updateHub = useUpdateHub();
const deleteHub = useDeleteHub(); const deleteHub = useDeleteHub();
@@ -175,7 +179,7 @@ export default function Hubs() {
sortable: true, sortable: true,
cell: (row) => <StatusBadge status={row.status} dot size="sm" />, cell: (row) => <StatusBadge status={row.status} dot size="sm" />,
}, },
{ !isClient && {
key: 'actions', key: 'actions',
header: 'Actions', header: 'Actions',
align: 'right', align: 'right',
@@ -192,8 +196,8 @@ export default function Hubs() {
</div> </div>
), ),
}, },
], ].filter(Boolean),
[] [isClient]
); );
return ( return (
@@ -244,9 +248,11 @@ export default function Hubs() {
placeholder="Search hubs, addresses or pincodes" placeholder="Search hubs, addresses or pincodes"
count={{ shown: rows.length, total: hubs.length, noun: 'hubs' }} count={{ shown: rows.length, total: hubs.length, noun: 'hubs' }}
actions={ actions={
<Button onClick={openCreate}> isClient ? null : (
<Plus className="mr-1.5 h-4 w-4" /> New hub <Button onClick={openCreate}>
</Button> <Plus className="mr-1.5 h-4 w-4" /> New hub
</Button>
)
} }
/> />
@@ -268,8 +274,12 @@ export default function Hubs() {
<EmptyState <EmptyState
icon={Warehouse} icon={Warehouse}
title="No hubs yet" title="No hubs yet"
description="Add the first sorting centre or delivery hub to start routing through the network." description={
action={{ label: 'New hub', icon: Plus, onClick: openCreate }} isClient
? 'Doormile has no hub in your city yet.'
: 'Add the first sorting centre or delivery hub to start routing through the network.'
}
action={isClient ? undefined : { label: 'New hub', icon: Plus, onClick: openCreate }}
/> />
) )
} }

View File

@@ -1,5 +1,11 @@
import React from 'react'; 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom'; 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 }; let mockAuth = { user: { email: 'admin@doormile.com', role: 'admin', tenantid: null }, isClient: false };
jest.mock('@/lib/AuthContext', () => ({ useAuth: () => mockAuth })); 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 }) => (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} value={value} onChange={(e) => onChange(e.target.value)} />
<button type="button" onClick={() => onPlaceSelected(mockPickedPlace)}>
Pick {label}
</button>
</div>
),
}));
import * as api from '@/api/doormile'; import * as api from '@/api/doormile';
import ClientOnboarding from '@/pages/doormile/clients/ClientOnboarding'; 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 = [ const CITIES = [
{ applocationid: 1, applocationname: 'Coimbatore', status: 'Active' }, { applocationid: 1, applocationname: 'Coimbatore', status: 'Active' },
@@ -67,6 +99,7 @@ const fillValidForm = async () => {
fill('Operating city', '1'); fill('Operating city', '1');
fireEvent.change(document.getElementById('onboard-password'), { target: { value: 's3cure-pass' } }); fireEvent.change(document.getElementById('onboard-password'), { target: { value: 's3cure-pass' } });
fill('Confirm password', 's3cure-pass'); fill('Confirm password', 's3cure-pass');
fireEvent.click(screen.getAllByRole('button', { name: 'Pick Client address' })[0]);
}; };
beforeEach(() => { beforeEach(() => {
@@ -99,6 +132,24 @@ describe('Edit and Delete on a client login', () => {
await waitFor(() => expect(api.updateOnboardedClient).toHaveBeenCalledWith(31, { phone: '9123456780', status: 'Inactive' })); 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 () => { it('resets the password only when one is entered, and checks its length', async () => {
renderPage(); renderPage();
fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' })); fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' }));
@@ -171,6 +222,12 @@ describe('Client onboarding page', () => {
password: 's3cure-pass', password: 's3cure-pass',
applocationid: 1, applocationid: 1,
requiredeliveryotp: false, 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('ops@acme.example');
expect(screen.getByRole('status')).toHaveTextContent('only time the password is shown'); expect(screen.getByRole('status')).toHaveTextContent('only time the password is shown');
@@ -198,6 +255,32 @@ describe('Client onboarding page', () => {
expect(api.onboardClient).not.toHaveBeenCalled(); 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 () => { 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' } } }); api.onboardClient.mockRejectedValue({ response: { status: 409, data: { success: false, message: 'this email already has a console login' } } });
renderPage(); renderPage();
@@ -277,6 +360,7 @@ describe('client onboarding helpers', () => {
expect(pw).toMatch(/[a-z]/i); expect(pw).toMatch(/[a-z]/i);
const errors = validateOnboarding({ const errors = validateOnboarding({
companyname: 'Acme', contactname: 'Priya', email: 'a@b.co', phone: '9876543210', applocationid: 1, password: pw, confirm: pw, 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({}); 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: 'a@b.co', confirm: 'a@b.co' }).password).toBeTruthy();
expect(validateOnboarding({ ...base, password: 'short', confirm: 'short' }).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({});
});
}); });

View File

@@ -65,7 +65,7 @@ describe('ZoneContext', () => {
it('should not load tenant locations for a staff login', () => { it('should not load tenant locations for a staff login', () => {
setup({ user: { tenantid: '0' } }); setup({ user: { tenantid: '0' } });
expect(useHubs).toHaveBeenCalledWith({ enabled: true }); expect(useHubs).toHaveBeenCalled();
expect(useTenantLocations).toHaveBeenCalledWith(null, { enabled: false }); expect(useTenantLocations).toHaveBeenCalledWith(null, { enabled: false });
}); });
}); });
@@ -79,17 +79,40 @@ describe('ZoneContext', () => {
longitude: 77.6408 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] }); const { result } = setup({ user: { tenantid: '4' }, locations: [LOCATION] });
expect(result.current.isTenantUser).toBe(true); 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].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', () => { it('should scope the location query to the operator own tenant', () => {
setup({ user: { tenantid: '4' }, locations: [LOCATION] }); setup({ user: { tenantid: '4' }, locations: [LOCATION] });
expect(useTenantLocations).toHaveBeenCalledWith('4', { enabled: true }); 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', () => { it('should treat tenantid "0" as staff, not as a tenant', () => {
@@ -425,7 +448,7 @@ describe('ZoneContext', () => {
expect(result.current.isHubsLoading).toBe(true); 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 refetchHubs = jest.fn();
const refetchLocations = jest.fn(); const refetchLocations = jest.fn();
useAuth.mockReturnValue({ user: { tenantid: '4' } }); useAuth.mockReturnValue({ user: { tenantid: '4' } });
@@ -436,7 +459,7 @@ describe('ZoneContext', () => {
act(() => result.current.refetchHubs()); act(() => result.current.refetchHubs());
expect(refetchLocations).toHaveBeenCalledTimes(1); expect(refetchLocations).toHaveBeenCalledTimes(1);
expect(refetchHubs).not.toHaveBeenCalled(); expect(refetchHubs).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@@ -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');
});
});

View File

@@ -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', () => { 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 // Two different id spaces. Matching them would file an order under a
// kitchen it has nothing to do with, on a bare numeric collision. // 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] }); 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, bookingid: 3, bookingno: 'DM-X', tenantid: '13', pickuphubid: 5,
pickupaddress: 'Gate 3', deliveryaddress: 'Block C' pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
})).toBe(false); })).toBe(false);
@@ -96,11 +98,28 @@ describe('a tenant session never matches on a hub id', () => {
it('but its own tenantlocationid still matches', () => { it('but its own tenantlocationid still matches', () => {
const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] }); 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, bookingid: 4, bookingno: 'DM-Y', tenantid: '13', tenantlocationid: 5,
pickupaddress: 'Gate 3', deliveryaddress: 'Block C' pickupaddress: 'Gate 3', deliveryaddress: 'Block C'
})).toBe(true); })).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', () => { 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', const KITCHEN = { tenantlocationid: 5, locationname: 'Indiranagar Kitchen',
city: 'Bengaluru', latitude: 12.9784, longitude: 77.6408, pincode: '560038' }; city: 'Bengaluru', latitude: 12.9784, longitude: 77.6408, pincode: '560038' };
const { result } = setup({ user: { tenantid: '13' }, locations: [KITCHEN] }); 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); 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); 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');
});
}); });