updates on the admin and zone things and minor changes
This commit is contained in:
23
src/App.jsx
23
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,
|
/* 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)];
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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
37
src/lib/consoleNav.js
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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={
|
||||||
|
isClient ? null : (
|
||||||
<Button onClick={openCreate}>
|
<Button onClick={openCreate}>
|
||||||
<Plus className="mr-1.5 h-4 w-4" /> Log exception
|
<Plus className="mr-1.5 h-4 w-4" /> Log exception
|
||||||
</Button>
|
</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 }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +2302,7 @@ 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. */}
|
||||||
|
{!isClient && (
|
||||||
<Link
|
<Link
|
||||||
to="/doormile/tripsheets"
|
to="/doormile/tripsheets"
|
||||||
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
|
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
|
||||||
@@ -2309,6 +2314,7 @@ export default function Home() {
|
|||||||
Tripsheets
|
Tripsheets
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2424,6 +2430,7 @@ export default function Home() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Clients */}
|
{/* Clients */}
|
||||||
|
{!isClient && (
|
||||||
<Link
|
<Link
|
||||||
to="/doormile/tenants"
|
to="/doormile/tenants"
|
||||||
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
|
className="flex flex-col items-center group focus:outline-none w-16 shrink-0 text-center cursor-pointer"
|
||||||
@@ -2435,6 +2442,7 @@ export default function Home() {
|
|||||||
Clients
|
Clients
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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={
|
||||||
|
isClient ? null : (
|
||||||
<Button onClick={openCreate}>
|
<Button onClick={openCreate}>
|
||||||
<Plus className="mr-1.5 h-4 w-4" /> New hub
|
<Plus className="mr-1.5 h-4 w-4" /> New hub
|
||||||
</Button>
|
</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 }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
51
tests/lib/consoleNav.test.js
Normal file
51
tests/lib/consoleNav.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user