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,
the solver payload builders and the comparison panels; loading that on the
way to the Hubs table would make every first paint pay for it. */
/* Doormile-only pages. A client login is sent home rather than shown a page
whose every request the server refuses. */
function StaffOnly({ children }) {
const { isClient } = useAuth();
return isClient ? <Navigate to="/doormile/home" replace /> : children;
}
const Dispatch = lazy(() => import('@/pages/doormile/dispatch/Dispatch.jsx'));
const DispatchPreview = lazy(() => import('@/pages/doormile/dispatch/Preview.jsx'));
@@ -126,10 +133,10 @@ const AuthenticatedApp = () => {
{/* Reverse logistics: parcels going back to the sender (RTO). */}
<Route path="returns" element={<Returns />} />
<Route path="tenants" element={<Tenants />} />
<Route path="clients/create" element={<CreateClient />} />
<Route path="tenants" element={<StaffOnly><Tenants /></StaffOnly>} />
<Route path="clients/create" element={<StaffOnly><CreateClient /></StaffOnly>} />
{/* 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="pricing" element={<Pricing />} />
@@ -145,11 +152,11 @@ const AuthenticatedApp = () => {
<Route path="customers" element={<Customers />} />
<Route path="hubs" element={<Hubs />} />
<Route path="vehicles" element={<Vehicles />} />
<Route path="app-users" element={<AppUsers />} />
<Route path="tripsheets" element={<Tripsheets />} />
<Route path="vehicles" element={<StaffOnly><Vehicles /></StaffOnly>} />
<Route path="app-users" element={<StaffOnly><AppUsers /></StaffOnly>} />
<Route path="tripsheets" element={<StaffOnly><Tripsheets /></StaffOnly>} />
<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/ordersdetails" element={<OrdersDetails />} />
@@ -162,7 +169,7 @@ const AuthenticatedApp = () => {
{/* The AI_engine swarm. Read-only: it reports what the agents are
and what they last did, and has no control surface, because
every autonomy gate lives in that repository's own env. */}
<Route path="agents" element={<Agents />} />
<Route path="agents" element={<StaffOnly><Agents /></StaffOnly>} />
</Route>
</Route>

View File

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

View File

@@ -23,10 +23,16 @@ export function ZoneProvider({ children }) {
const tenantId = user?.tenantid || localStorage.getItem('tenantid') || '';
const isTenantUser = Boolean(tenantId && tenantId !== '0');
// Staff loads global hubs; Tenant loads tenant locations / kitchen hubs
const { data: rawHubs = [], isLoading: isHubsLoading, refetch: refetchHubs } = useHubs({
enabled: !isTenantUser,
});
// A client login's city (from the login response), used to offer them the
// Doormile hubs of their own city. Missing on a session signed in before the
// backend sent it, in which case every active hub is offered.
const clientCityId = user?.applocationid != null && String(user.applocationid) !== '0' ? String(user.applocationid) : '';
// Staff see Doormile's hubs. A client sees their own locations (kitchens,
// depots) AND the Doormile hubs of their city: a newly onboarded client had
// no locations at all, so their zone list was just "All Zones" and the
// Coimbatore hub their orders run through never appeared.
const { data: rawHubs = [], isLoading: isHubsLoading, refetch: refetchHubs } = useHubs();
const { data: tenantLocations = [], isLoading: isLocationsLoading, refetch: refetchLocations } = useTenantLocations(
isTenantUser ? tenantId : null,
{ enabled: isTenantUser }
@@ -41,10 +47,21 @@ export function ZoneProvider({ children }) {
});
const activeHubs = useMemo(() => {
const doormileHubs = (rawHubs || []).filter(
(hub) => String(hub.status || 'Active').toLowerCase() === 'active'
);
if (isTenantUser) {
return (tenantLocations || []).map((loc) => ({
hubid: String(loc.tenantlocationid || loc.locationid || loc.id),
id: String(loc.tenantlocationid || loc.locationid || loc.id),
// A client location's id and a hub id are separate numberings, so the
// location's zone id is prefixed; `locationid` keeps the raw one for
// matching orders (matchesZone).
// A deactivated location is no longer a place the client ships from;
// it was listed (and labelled Active) regardless until now.
const own = (tenantLocations || [])
.filter((loc) => String(loc.status || 'Active').toLowerCase() === 'active')
.map((loc) => ({
hubid: `loc:${loc.tenantlocationid || loc.locationid || loc.id}`,
id: `loc:${loc.tenantlocationid || loc.locationid || loc.id}`,
locationid: String(loc.tenantlocationid || loc.locationid || loc.id),
hubname: loc.locationname || loc.name || `Kitchen Hub #${loc.tenantlocationid || loc.locationid}`,
city: loc.city || '',
address: loc.address || '',
@@ -55,12 +72,14 @@ export function ZoneProvider({ children }) {
isTenantLocation: true,
status: 'Active',
}));
const cityHubs = clientCityId
? doormileHubs.filter((hub) => String(hub.applocationid ?? '') === clientCityId)
: doormileHubs;
return [...own, ...cityHubs];
}
return (rawHubs || []).filter(
(hub) => String(hub.status || 'Active').toLowerCase() === 'active'
);
}, [isTenantUser, tenantLocations, rawHubs]);
return doormileHubs;
}, [isTenantUser, tenantLocations, rawHubs, clientCityId]);
const zones = useMemo(() => {
return [ALL_ZONE, ...activeHubs];
@@ -178,10 +197,15 @@ export function ZoneProvider({ children }) {
// The old `??` chain hid this by stopping at the first non-null field, so
// the collision only fired when the earlier ones happened to be absent. It
// was a bug waiting on its inputs, not a safe design.
const itemHubIds = isTenantUser
//
// What decides the id space is the selected ZONE, not who is signed in: a
// client can now pick one of their own locations (compare location ids)
// or a Doormile hub (compare hub ids).
const itemHubIds = currentHub.isTenantLocation
? [item.tenantlocationid, item.locationid]
: [item.servicinghubid, item.pickuphubid, item.nearesthubid, item.hubid, item.sourcehubid];
if (itemHubIds.some((id) => id != null && String(id) === targetHubId)) {
const targetId = currentHub.isTenantLocation ? String(currentHub.locationid) : targetHubId;
if (itemHubIds.some((id) => id != null && String(id) === targetId)) {
return true;
}
@@ -232,8 +256,13 @@ export function ZoneProvider({ children }) {
isAllZones,
isTenantUser,
tenantId,
isHubsLoading: isTenantUser ? isLocationsLoading : isHubsLoading,
refetchHubs: isTenantUser ? refetchLocations : refetchHubs,
isHubsLoading: isTenantUser ? isLocationsLoading || isHubsLoading : isHubsLoading,
refetchHubs: isTenantUser
? () => {
refetchLocations();
refetchHubs();
}
: refetchHubs,
matchesZone,
}),
[

View File

@@ -53,9 +53,75 @@ export function validateOnboarding(form) {
errors.password = 'Must not be the email or the phone number';
}
if (form.confirm !== pw) errors.confirm = 'The passwords do not match';
Object.assign(errors, validateAddress(form));
return errors;
}
/**
* The client's main address. Mirrors the server: an address, a 6-digit
* pincode, and map coordinates — which only come from picking a suggestion,
* so typed text alone is refused.
*/
export function validateAddress(form) {
const errors = {};
if (String(form.address || '').trim().length < 5) errors.address = "Enter the client's address";
else if (String(form.address).trim().length > 300) errors.address = 'At most 300 characters';
else if (!hasMapLocation(form)) errors.address = 'Pick the address from the suggestions so it has a map location';
if (!/^[1-9]\d{5}$/.test(String(form.pincode || '').replace(/\s/g, ''))) errors.pincode = 'Enter a 6-digit pincode';
return errors;
}
/** Real coordinates inside India (zero means nothing was picked). */
export function hasMapLocation(form) {
const lat = Number(form.latitude);
const lng = Number(form.longitude);
return Number.isFinite(lat) && Number.isFinite(lng) && lat >= 6 && lat <= 37.5 && lng >= 68 && lng <= 97.5;
}
/**
* The address fields from a place picked in AddressAutocomplete (the same
* shapes the order form reads: our own fields first, Google-style
* address_components as a fallback).
*/
export function addressFromPlace(place) {
if (!place) return {};
const parts = { city: '', state: '', pincode: '' };
(place.address_components || []).forEach((c) => {
(c.types || []).forEach((type) => {
if (type === 'locality') parts.city = c.long_name;
if (type === 'administrative_area_level_1') parts.state = c.long_name;
if (type === 'postal_code') parts.pincode = c.long_name;
});
});
const g = place.geometry?.location;
const read = (v) => (typeof v === 'function' ? v() : v);
const lat = place.latitude != null ? place.latitude : read(g?.lat);
const lng = place.longitude != null ? place.longitude : read(g?.lng);
const out = {
address: place.formatted_address || place.name || '',
city: place.city || parts.city,
state: place.state || parts.state,
pincode: String(place.postcode || parts.pincode || '').replace(/\s/g, ''),
};
if (lat != null && lng != null) {
out.latitude = Number(lat);
out.longitude = Number(lng);
}
return Object.fromEntries(Object.entries(out).filter(([, v]) => v !== '' && v != null));
}
/** The address part of a form, as the API takes it. */
export function addressPayload(form) {
return {
address: String(form.address || '').trim(),
city: String(form.city || '').trim(),
state: String(form.state || '').trim(),
pincode: String(form.pincode || '').replace(/\s/g, ''),
latitude: Number(form.latitude),
longitude: Number(form.longitude),
};
}
/**
* Why onboarding cannot run, from the failed GET /admin/clients/cities. The
* page shows this instead of an empty city list: the likeliest cause is a

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 { inputVariants } from '@/components/ui/input';
import { useDeleteOnboardedClient, useUpdateOnboardedClient } from '@/lib/doormileHooks';
import { generateClientPassword } from '@/lib/clientOnboarding';
import AddressAutocomplete from '@/components/doormile/AddressAutocomplete';
import { addressFromPlace, addressPayload, generateClientPassword, validateAddress } from '@/lib/clientOnboarding';
/**
* Edit and Remove for one onboarded client login (a row of "Clients with a
@@ -24,6 +25,28 @@ export function changedFields(row, form) {
if (form.status !== row.status) out.status = form.status;
if (Boolean(form.requiredeliveryotp) !== Boolean(row.requiredeliveryotp)) out.requiredeliveryotp = Boolean(form.requiredeliveryotp);
if (form.password) out.password = form.password;
// The address goes as one `location` object, and only when any part of it
// changed (a coordinate change alone counts: re-picking the same text moves
// the pin).
const addr = addressPayload(form);
const before = {
address: trim(row.address),
city: trim(row.city),
state: trim(row.state),
pincode: String(row.pincode || ''),
latitude: Number(row.latitude) || 0,
longitude: Number(row.longitude) || 0,
};
if (
addr.address !== before.address ||
addr.pincode !== before.pincode ||
addr.city !== before.city ||
addr.state !== before.state ||
(Number(addr.latitude) || 0) !== before.latitude ||
(Number(addr.longitude) || 0) !== before.longitude
) {
out.location = addr;
}
return out;
}
@@ -35,6 +58,7 @@ export function validateEdit(form, changes) {
if ('phone' in changes && !/^[6-9]\d{9}$/.test(changes.phone)) errors.phone = 'Enter a 10-digit mobile number';
if (form.password && form.password.length < 8) errors.password = 'At least 8 characters';
if (form.password && form.password.length > 72) errors.password = 'At most 72 characters';
if ('location' in changes) Object.assign(errors, validateAddress(form));
return errors;
}
@@ -47,6 +71,12 @@ function formFrom(row) {
status: STATUSES.includes(row?.status) ? row.status : 'Active',
requiredeliveryotp: Boolean(row?.requiredeliveryotp),
password: '',
address: row?.address || '',
city: row?.city || '',
state: row?.state || '',
pincode: row?.pincode || '',
latitude: row?.latitude || '',
longitude: row?.longitude || '',
};
}
@@ -172,6 +202,39 @@ export function EditClientModal({ row, onClose }) {
</Button>
)}
</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">
<Switch checked={form.requiredeliveryotp} onCheckedChange={set('requiredeliveryotp')} />
</Field>

View File

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

View File

@@ -11,6 +11,7 @@ import { StartRtoModal } from '@/components/doormile/RtoDialogs';
import { useCreateException, useExceptions, useHubs, useUpdateExceptionStatus } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
import { useAuth } from '@/lib/AuthContext';
/**
* 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 [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 updateStatus = useUpdateExceptionStatus();
@@ -197,7 +201,7 @@ export default function Exceptions() {
),
hideBelow: 'lg',
},
{
!isClient && {
key: 'actions',
header: 'Actions',
align: 'right',
@@ -227,14 +231,14 @@ export default function Exceptions() {
</div>
),
},
],
[hubMap]
].filter(Boolean),
[hubMap, isClient]
);
return (
<Stack space="lg">
{/* Early warnings from the console's monitoring skills (agent registry). */}
<AgentOperationsBanner />
{!isClient && <AgentOperationsBanner />}
<Grid cols={4}>
<KpiCard
@@ -282,9 +286,11 @@ export default function Exceptions() {
placeholder="Search by consignment, type or description"
count={{ shown: rows.length, total: exceptions.length, noun: 'exceptions' }}
actions={
<Button onClick={openCreate}>
<Plus className="mr-1.5 h-4 w-4" /> Log exception
</Button>
isClient ? null : (
<Button onClick={openCreate}>
<Plus className="mr-1.5 h-4 w-4" /> Log exception
</Button>
)
}
/>
@@ -307,7 +313,7 @@ export default function Exceptions() {
icon={CheckCircle2}
title="Nothing has gone wrong"
description="No exceptions have been raised against a consignment."
action={{ label: 'Log exception', icon: Plus, onClick: openCreate }}
action={isClient ? undefined : { label: 'Log exception', icon: Plus, onClick: openCreate }}
/>
)
}

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 { answerSmallTalk, CAPABILITIES } from '@/lib/assistant/smalltalk';
import { buildRepeatRun, describeDay } from '@/lib/assistant/repeatRuns';
import { useAuth } from '@/lib/AuthContext';
const MILETRUTH_SUGGESTIONS = [
"Repeat yesterday's orders",
@@ -465,6 +466,9 @@ function HomeFlowDropdown({ step, draft, isBusy, onChoose, onCancel }) {
export default function Home() {
const navigate = useNavigate();
// Tripsheets and Clients are Doormile-only pages; a client login gets no
// launcher tile for either.
const { isClient } = useAuth();
const queryClient = useQueryClient();
// State
@@ -2298,17 +2302,19 @@ export default function Home() {
Tripsheets, and the domain object is a tripsheet. A launcher
tile whose label does not match its destination costs the
operator a click to find that out, every time. */}
<Link
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>
<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">
Tripsheets
</span>
</Link>
{!isClient && (
<Link
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>
<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">
Tripsheets
</span>
</Link>
)}
</div>
</div>
@@ -2424,17 +2430,19 @@ export default function Home() {
</Link>
{/* Clients */}
<Link
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>
<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">
Clients
</span>
</Link>
{!isClient && (
<Link
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>
<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">
Clients
</span>
</Link>
)}
</div>
</div>

View File

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