updates on the ui and updates agents

This commit is contained in:
2026-10-09 13:39:14 +05:30
parent 3d07ea8170
commit 5faf0e1deb
13 changed files with 1572 additions and 45 deletions

View File

@@ -545,14 +545,27 @@ export const assignMilerToBooking = async (id, data) => {
return response.data;
};
// Doormile-native bulk assign: picks real milers via Redis GEO + AI scoring, no
// external solver involved. Commits server-side in this one call — unlike the
// workolik solver flow, there's no separate preview/reconcile/commit step.
// Sequencing (POST /optimization/doormile/sequence) is not deployed yet, so
// riders with more than one stop come back unsequenced (step: 0) — see
// doormile-flow.md's State table.
// Doormile-native bulk assign: greedy nearest on-duty rider by haversine,
// capped per rider, committed server-side in this one call — there is no
// separate preview/reconcile/commit step.
//
// ADMIN endpoint, not the hub one. It used to point at
// POST /hub/bookings/batch-assign, which sits behind HubStaffAuth and 403s for
// every token this console issues — so this function could never succeed from
// here. /admin/bookings/batch-assign runs the SAME solver
// (controllers/batchAssignService.go) with admin auth, which is what makes the
// ops layer's assignMiler proposal executable at all.
//
// Unlike the hub route, an empty bookingids is refused rather than meaning
// "clear the queue": an admin login has no hub bound, so empty would mean every
// pending booking in the system.
//
// Stop sequencing runs after the assignments commit and is best-effort. It
// needs ROUTE_OPTIMIZER_URL set in the cluster; without it riders with more
// than one stop come back unsequenced (step: 0) — see doormile-flow.md's State
// table.
export const batchAssignBookings = async (bookingIds, maxPerRider = 5) => {
const response = await doormileAxios.post('/hub/bookings/batch-assign', {
const response = await doormileAxios.post('/admin/bookings/batch-assign', {
bookingids: bookingIds,
max_per_rider: maxPerRider
});
@@ -860,6 +873,44 @@ export const runAiPlayground = async (body) => {
return response.data.data;
};
/* Skill findings. The console's rule output, reported so it survives the render
that produced it — see lib/assistant/agent/findingReport.js for why. Called
fire-and-forget: a failed report must never surface to an operator looking at
a stalled parcel. */
export const reportFindings = async (payload) => {
const response = await doormileAxios.post('/admin/ai/findings', payload);
return response.data;
};
export const reportFindingActed = async (fingerprint, result) => {
const response = await doormileAxios.post(
`/admin/ai/findings/${encodeURIComponent(fingerprint)}/acted`, { result }
);
return response.data;
};
export const getAiFindings = async ({ days, skill, limit } = {}) => {
const response = await doormileAxios.get(`/admin/ai/findings${buildQuery({ days, skill, limit })}`);
return response.data.data;
};
/** Per skill: how often it fires, how long findings stay open, how often anyone
acts, and whether acting cleared them. "Acted and cleared" vs "cleared on
its own" is what separates a skill that helps from one that narrates. */
export const getAiFindingStats = async (days = 30) => {
const response = await doormileAxios.get(`/admin/ai/findings/stats${buildQuery({ days })}`);
return response.data.data;
};
/** Expected pickups per zone with the STAFFING GAP against riders on duty.
A bare forecast number is not actionable — nobody knows whether 42 is fine.
The gap is: "641 expects 42, has 6 riders at 5 stops each, short by 12".
Produced daily by AI_engine/prediction and stored by the backend. */
export const getDemandForecast = async (days = 7) => {
const response = await doormileAxios.get(`/admin/ai/forecast/demand${buildQuery({ days })}`);
return response.data.data;
};
/** Recent agent decisions, newest first. `before` is the last id of the previous page. */
export const getAiDecisions = async ({ type, before, limit } = {}) => {
const response = await doormileAxios.get(`/admin/ai/decisions${buildQuery({ type, before, limit })}`);

View File

@@ -6,6 +6,7 @@ import SlaRemediationCard from './SlaRemediationCard';
import { scanBookings } from '@/lib/assistant/scan';
import { toAgentRows } from '@/lib/assistant/agent/normalise';
import { AgentFactory } from '@/lib/assistant/agent/AgentFactory';
import { reportScan, reportActed } from '@/lib/assistant/agent/findingReport';
import { SkillRegistry } from '@/lib/assistant/skills/SkillRegistry';
import { wallClockNow } from '@/lib/assistant/agent/signals';
import { executeProposal, canExecuteProposal } from '@/lib/assistant/agent/actions';
@@ -62,7 +63,12 @@ export default function AgentOperationsBanner({
const scan = await scanBookings();
const agentRows = toAgentRows(scan?.rows);
const agent = AgentFactory.synthesizeDefaultAgent();
return agent.evaluateTelemetry(agentRows, wallClockNow());
const findings = agent.evaluateTelemetry(agentRows, wallClockNow());
// Reported AFTER evaluating and deliberately NOT awaited: persistence is
// observability and must never sit in front of what the operator sees.
// reportScan never throws.
reportScan(findings);
return findings;
},
staleTime: 30_000,
refetchInterval: 60_000,
@@ -81,6 +87,10 @@ export default function AgentOperationsBanner({
const handleRemediate = useCallback(
async (finding) => {
const result = await executeProposal(finding);
// Recorded before the throw: a failed action is exactly the outcome worth
// keeping, and recording only successes would make every skill look
// perfect.
reportActed(finding, result);
if (!result?.ok) {
throw new Error(result?.message || 'The action did not complete.');
}

View File

@@ -165,7 +165,7 @@ export const AGENTS = [
load: 0,
blurb: 'Builds delivery routes from zones and available hubs. Ten-minute cache.',
detail:
'Sequences waypoints into routes using zone membership and hub availability. This is the piece the Go backend has no equivalent for — HubBatchAssign decides who gets a booking, never what order a rider runs their stops in.',
'Sequences waypoints into routes using zone membership and hub availability. SIMULATION: its zones and traffic patterns are hard-coded in _init_zones()/_init_traffic_patterns() and it makes no backend or routing-API call at all. Real stop ordering is done by internal/routing against the Valhalla-backed Route Optimization API, not here. The registry records it with status simulation.',
subscribes: [],
publishes: 'Nothing on the bus.',
api: 'No.',
@@ -186,7 +186,7 @@ export const AGENTS = [
load: 0,
blurb: 'Hub operations — transit records, capacity, what is sitting where.',
detail:
'Tracks inventory at each hub and the transit records moving between them, and answers capacity questions about whether a hub can absorb more volume.',
'Tracks inventory at each hub and the transit records moving between them, and answers capacity questions about whether a hub can absorb more volume. SIMULATION: its hubs are 8 hard-coded entries in _init_hubs() (DL-HUB-01 and friends, with invented connected_hubs graphs) — not the hubs table. The registry records it with status simulation.',
subscribes: [],
publishes: 'Nothing on the bus.',
api: 'No.',
@@ -207,7 +207,7 @@ export const AGENTS = [
load: 0,
blurb: 'Vehicles: status, capacity and live tracking.',
detail:
"Holds vehicle state and assignment, and answers which vehicle can carry a given load. It overlaps with the Go backend's own Vehicle model, which is the live source today.",
"Holds vehicle state and assignment, and answers which vehicle can carry a given load. It overlaps with the Go backend's own Vehicle model, which is the live source today. SIMULATION: its fleet is 19 vehicles hard-coded in _init_fleet() across Delhi, Mumbai, Bangalore, Hyderabad, Pune and Kolkata — not the vehicles table, and not the cities Doormile serves. The registry records it with status simulation for this reason.",
subscribes: [],
publishes: 'Nothing on the bus.',
api: 'No.',

View File

@@ -17,7 +17,7 @@
// • A partial success is a partial success. Notifying six riders out of eight
// is reported as six out of eight, with the two failures named.
import { getMilers, buildMilerLookup, notifyRider } from '@/api/doormile';
import { getMilers, buildMilerLookup, notifyRider, batchAssignBookings } from '@/api/doormile';
/** A stable, human-facing reference for a row. */
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
@@ -86,6 +86,9 @@ const executeNotifyRider = async (scope, message) => {
return {
ok: notified.length > 0,
// Same contract as executeAssignMiler: say whether this was partial rather
// than leaving every caller to work it out from a different field.
partial: notified.length > 0 && unreachable.length > 0,
message: parts.join(' ') || 'Nothing was sent.',
notified,
unreachable,
@@ -93,18 +96,86 @@ const executeNotifyRider = async (scope, message) => {
};
};
// ---- assignMiler: deliberately NOT an executor --------------------------------
// ---- assignMiler ------------------------------------------------------------
//
// On the branch this called batchAssignBookings → POST /hub/bookings/batch-assign.
// That route sits behind middlewares.HubStaffAuth, which refuses every token
// whose role is not 6 (hub staff) — so from this console, where every login is
// admin/manager/executive, it would 403 on every click. Shipping it would have
// put a button on the Exceptions banner that could never succeed.
// This WAS review-only, and the note explaining why is worth keeping because
// it names the actual constraint:
//
// Findings that propose `assignMiler` therefore render "Review only". The
// admin route that could back it, POST /admin/bookings/:id/assign-miler, needs
// a chosen rider per booking, which a finding does not pick. Wiring this is a
// backend decision (an admin batch-assign route), not a console one.
// On the branch this called batchAssignBookings → POST /hub/bookings/batch-assign.
// That route sits behind middlewares.HubStaffAuth, which refuses every token
// whose role is not 6 (hub staff) — so from this console, where every login is
// admin/manager/executive, it would 403 on every click. The admin route that
// could back it, POST /admin/bookings/:id/assign-miler, needs a chosen rider
// per booking, which a finding does not pick.
//
// The resolution was the backend decision that note called for:
// POST /admin/bookings/batch-assign now runs the same solver as the hub route
// (controllers/batchAssignService.go) under admin auth, so the console gets the
// solver that PICKS riders rather than one that needs them named.
//
// ⚠ This commits. There is no preview/reconcile step on either batch-assign
// route, so the proposal gate in the UI is the only thing between a finding and
// a real assignment. canExecuteProposal must stay the gate, and the card must
// keep requiring an explicit click — do not make this run automatically.
const executeAssignMiler = async (scope) => {
const rows = (scope || []).filter((r) => r?.bookingid);
if (!rows.length) {
return { ok: false, message: 'None of these findings carries a booking to assign.', sourceCalls: [] };
}
const bookingIds = [...new Set(rows.map((r) => Number(r.bookingid)))];
const target = 'POST /admin/bookings/batch-assign';
try {
const res = await batchAssignBookings(bookingIds);
// The backend reports per booking, and a partial success is a partial
// success — the same rule executeNotifyRider follows. Reporting "assigned"
// when four of nine landed sends a dispatcher away from five parcels that
// still have nobody.
const data = res?.data || res || {};
const assigned = Number(data.assigned ?? 0);
const skipped = Number(data.skipped ?? 0);
const sequenced = Number(data.riderssequenced ?? 0);
const reasons = (data.results || [])
.filter((r) => !r?.assigned && r?.reason)
.map((r) => `${r.bookingno || `#${r.bookingid}`} (${r.reason})`);
const parts = [];
if (assigned) parts.push(`Assigned ${assigned} order${assigned === 1 ? '' : 's'}.`);
if (skipped) parts.push(`Could not assign ${skipped}: ${reasons.join('; ') || 'no reason given'}.`);
// Stop order needs ROUTE_OPTIMIZER_URL configured. Saying nothing when it
// is zero would leave the operator assuming routes were ordered.
if (assigned && !sequenced) parts.push('Stops were not sequenced (route optimiser unavailable).');
else if (sequenced) parts.push(`Sequenced ${sequenced} rider${sequenced === 1 ? '' : 's'}.`);
return {
ok: assigned > 0,
// Declared, not inferred. reportActed used to detect a partial run by
// looking for an `unreachable` array, which only the notify executor
// returns — so a batch that assigned two of three was recorded as a
// clean success. The message said "partial", the telemetry said "ok",
// and the measurement the findings table exists for was wrong.
partial: assigned > 0 && skipped > 0,
message: parts.join(' ') || 'Nothing was assigned.',
sourceCalls: [{
name: 'batchAssignBookings',
target,
status: assigned > 0 ? 'complete' : 'error',
stats: `${assigned} assigned, ${skipped} skipped, ${sequenced} sequenced`,
...(assigned === 0 ? { errorMessage: reasons.join('; ') || 'no rider under capacity' } : {})
}]
};
} catch (err) {
return {
ok: false,
message: err?.message || 'The assignment did not complete.',
sourceCalls: [{
name: 'batchAssignBookings', target, status: 'error',
errorMessage: err?.message || 'assignment failed'
}]
};
}
};
// ---- registry ---------------------------------------------------------------
@@ -127,8 +198,38 @@ const EXECUTORS = {
finding?.severity === 'critical'
? 'Urgent: this delivery needs attention now. Please check the app.'
: 'Please check this delivery in the app when you can.'
)
// assignMiler: none — see the note above. Its findings render "Review only".
),
// alert_low_battery_rider: the same endpoint notifyRider already uses, so
// this is a message, not a new capability. The registry seed names
// POST /admin/milers/:id/notify as its target and it was marked REVIEW ONLY
// only because nothing had been wired to it.
//
// The tool key is snake_case here, unlike notifyRider above, because
// RiderBatterySafetySkill.js:84 emits `alert_low_battery_rider` and this map
// is keyed on the verb a finding actually carries — not on a convention.
// Renaming the skill's verb to match would be the tidier fix and a wider
// change; it is not worth touching a shipped skill's output for.
alert_low_battery_rider: (finding) =>
executeNotifyRider(
finding?.proposal?.scope,
'Low battery: please charge now, or report to the nearest hub if you cannot.'
),
assignMiler: (finding) => executeAssignMiler(finding?.proposal?.scope),
// trigger_auto_dispatch: LateDispatchSkill proposes "auto-assign orders that
// have waited too long for dispatch", which is exactly what batch-assign
// does. seed.go marked it Target: "none yet" because no route picked riders
// for an admin login; /admin/bookings/batch-assign now does, so this needed
// no new capability — only the realisation that it is the same action under
// a different name.
//
// Deliberately NOT given its own executor: two code paths assigning riders
// would be a second definition of the same write, and the codebase already
// carries three (internal/assignment's AI path, AssignMilerToBooking, and
// the batch solver). One more would be the one that drifts.
trigger_auto_dispatch: (finding) => executeAssignMiler(finding?.proposal?.scope)
};
/** Whether a finding's proposal can actually be carried out. */

View File

@@ -0,0 +1,150 @@
// ==============================|| Doormile AI — finding persistence ||============================== //
//
// Reports what the rule skills noticed to the backend, so it survives the
// render that produced it.
//
// The eight skills run here, in the operator's browser, on a 60-second React
// Query interval — and their output was discarded every cycle. Three questions
// had no answer at all:
//
// • Has this booking been flagged before, and how many times?
// • Which skills fire most, and which are ignored every single time?
// • Did the proposal an operator carried out actually clear the finding?
//
// The third is the one worth having. AgentOperationsBanner already re-runs its
// scan after executing a proposal precisely to see whether the finding
// disappears — that evidence existed for one render and then vanished.
//
// ─── Rules this module follows ───────────────────────────────────────────────
//
// • It is FIRE-AND-FORGET and never throws. Reporting is observability; the
// briefing must render identically whether or not the POST lands. A
// dispatcher looking at a stalled parcel does not care that telemetry is
// down, and must not be shown an error about it.
//
// • It sends the CLEARED set too. Only the console knows the full set it
// evaluated, so only it can tell the backend which fingerprints are gone.
// The backend cannot distinguish "resolved" from "the operator closed the
// tab".
//
// • It sends no personal data. Scope goes as booking ids only — no names,
// phones or addresses. The findings table is for measuring the skills, not
// a second copy of the bookings.
import { reportFindings, reportFindingActed } from '@/api/doormile';
/**
* The identity of a finding across polls.
*
* Deliberately only the skill and its scope's booking ids, sorted. NOT severity
* and NOT counts: both drift while the underlying problem is unchanged (an
* ageing SLA breach climbs from warning to critical), and including them would
* make every escalation look like a brand-new finding and reset the "how long
* has this been open" measurement — which is the single most useful thing the
* table records.
*
* Sorted because scope order is not stable across scans.
*/
export const fingerprintOf = (finding) => {
const skill = finding?.skillId || finding?.skill || 'unknown';
// The PROPOSED ACTION is part of the identity.
//
// One skill legitimately raises several findings over the same booking set —
// SlaGuardian emits both "notify the assigned riders" and "assign these
// orders" for the same three parcels. Keyed on skill + scope alone those
// collide, and because the fingerprint is the upsert key the second finding
// silently overwrites the first: one of the two disappears from the table and
// from every measurement built on it.
//
// Caught by running the real Exceptions page against a mock backend; the unit
// tests used a distinct skill per fingerprint and never produced a collision.
const tool = finding?.proposal?.tool || 'none';
const ids = (finding?.proposal?.scope || [])
.map((r) => r?.bookingid)
.filter((id) => id != null)
.map(Number)
.sort((a, b) => a - b);
return `${skill}:${tool}:${ids.join(',')}`;
};
const toItem = (finding) => ({
skillid: finding?.skillId || finding?.skill || 'unknown',
fingerprint: fingerprintOf(finding),
severity: finding?.severity || 'info',
title: String(finding?.title || finding?.headline || '').slice(0, 300),
proposaltool: finding?.proposal?.tool || '',
// Ids only. See the no-personal-data rule above.
bookingids: (finding?.proposal?.scope || [])
.map((r) => r?.bookingid)
.filter((id) => id != null)
.map(Number),
tenantid: Number(localStorage.getItem('tenantid')) || null
});
// Fingerprints seen on the previous scan, so this one can report what went
// away. Module-level rather than component state: the banner remounts on
// navigation and a remount must not look like every finding clearing at once.
let previous = new Set();
/**
* Report one scan's findings. Returns nothing and never rejects.
*
* Call AFTER the findings are rendered, not before — this must never sit in
* front of what the operator sees.
*/
export const reportScan = async (findings) => {
try {
const items = (findings || []).map(toItem).filter((i) => i.fingerprint && i.skillid);
const current = new Set(items.map((i) => i.fingerprint));
const cleared = [...previous].filter((fp) => !current.has(fp));
// Nothing open and nothing cleared is the common case on a quiet board;
// skip the round trip entirely.
if (!items.length && !cleared.length) {
previous = current;
return;
}
await reportFindings({ findings: items, cleared });
previous = current;
} catch {
// Silent by design. A failed report is not something an operator can act
// on, and the previous set is deliberately NOT updated — so the next scan
// retries the same clear set rather than losing it.
}
};
/**
* Record that an operator carried out a proposal, and how it went.
*
* `result` mirrors what the executors actually return: a partial success is a
* partial success. Flattening six-riders-notified-out-of-eight to "ok" would
* lose exactly the distinction actions.js preserves.
*/
export const reportActed = async (finding, executionResult) => {
try {
const fp = fingerprintOf(finding);
if (!fp) return;
let result = 'failed';
if (executionResult?.ok) {
// `partial` is declared by the executor. The fallback covers an executor
// that predates the field; without the declaration a batch-assign that
// skipped a booking reported as a clean success, because `unreachable`
// is a notify-only field.
const partial = typeof executionResult.partial === 'boolean'
? executionResult.partial
: (Array.isArray(executionResult?.unreachable) && executionResult.unreachable.length > 0);
result = partial ? 'partial' : 'ok';
}
await reportFindingActed(fp, result);
} catch {
// Same reasoning as reportScan: the action itself already happened and was
// reported to the operator. Losing its telemetry must not surface as a
// failure of the action.
}
};
/** Tests only — the module-level previous set would otherwise leak between them. */
export const __resetForTests = () => {
previous = new Set();
};

View File

@@ -38,6 +38,29 @@ export function generateClientPassword(length = 14) {
}
/** Client-side mirror of the server's validation, for inline errors. */
/**
* What a client ships. The SAME eight values the backend prices against
* (constants.DeliveryCategories) — a tenant whose category is not a pricing
* category cannot be priced, so this must not drift from that list.
*/
export const DELIVERY_CATEGORIES = [
'General', 'Documents', 'Electronics', 'Clothing',
'Fragile', 'Medical', 'Automotive', 'Food'
];
/**
* Whether a return journey makes sense for what this client ships.
*
* Food is the exception: a meal that comes back is waste, not inventory.
* Mirrors constants.ReverseLogisticsAllowed, which ENFORCES this in
* InitiateConsignmentRTO — this copy only decides what the form shows, and
* hiding a control is a courtesy, not a rule.
*
* Unknown or empty allows returns, matching the server: the field is new, and
* new information must not withdraw a capability existing clients already use.
*/
export const reverseLogisticsAllowed = (category) => category !== 'Food';
export function validateOnboarding(form) {
const errors = {};
const phone = String(form.phone || '').replace(/\D/g, '');
@@ -46,6 +69,12 @@ export function validateOnboarding(form) {
if (!/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(String(form.email || '').trim())) errors.email = 'Enter a valid email address';
if (!/^[6-9]\d{9}$/.test(phone)) errors.phone = 'Enter a 10-digit mobile number';
if (!form.applocationid) errors.applocationid = "Choose the client's operating city";
// Required, matching the server. Not defaulted to General: the category sets
// the client's pricing AND whether their parcels can be returned, so
// guessing it decides both on the operator's behalf without asking.
if (!DELIVERY_CATEGORIES.includes(String(form.deliverycategory || ''))) {
errors.deliverycategory = 'Choose what this client delivers';
}
const pw = String(form.password || '');
if (pw.length < 8) errors.password = 'At least 8 characters';
else if (pw.length > 72) errors.password = 'At most 72 characters';

View File

@@ -4,7 +4,14 @@ import { Alert, Button, Field, Input, Modal, Switch } from '@/components/ds';
import { inputVariants } from '@/components/ui/input';
import { useDeleteOnboardedClient, useUpdateOnboardedClient } from '@/lib/doormileHooks';
import AddressAutocomplete from '@/components/doormile/AddressAutocomplete';
import { addressFromPlace, addressPayload, generateClientPassword, validateAddress } from '@/lib/clientOnboarding';
import {
DELIVERY_CATEGORIES,
addressFromPlace,
addressPayload,
generateClientPassword,
reverseLogisticsAllowed,
validateAddress
} from '@/lib/clientOnboarding';
/**
* Edit and Remove for one onboarded client login (a row of "Clients with a
@@ -24,6 +31,10 @@ export function changedFields(row, form) {
if (form.phone.replace(/\D/g, '') !== String(row.primarycontact || '').replace(/\D/g, '')) out.phone = form.phone.replace(/\D/g, '');
if (form.status !== row.status) out.status = form.status;
if (Boolean(form.requiredeliveryotp) !== Boolean(row.requiredeliveryotp)) out.requiredeliveryotp = Boolean(form.requiredeliveryotp);
// Omitted when unchanged: the server leaves an absent category alone, which
// is what lets a client onboarded before this field existed be edited
// without being forced to pick one.
if (trim(form.deliverycategory) !== trim(row.deliverycategory)) out.deliverycategory = trim(form.deliverycategory);
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
@@ -69,6 +80,7 @@ function formFrom(row) {
email: row?.loginemail || '',
phone: String(row?.primarycontact || '').replace(/\D/g, ''),
status: STATUSES.includes(row?.status) ? row.status : 'Active',
deliverycategory: row?.deliverycategory || '',
requiredeliveryotp: Boolean(row?.requiredeliveryotp),
password: '',
address: row?.address || '',
@@ -235,6 +247,31 @@ export function EditClientModal({ row, onClose }) {
<Field label="City">
<Input value={form.city} maxLength={80} onChange={(e) => set('city')(e.target.value)} />
</Field>
<Field
label="What do they deliver?"
className="sm:col-span-2"
hint={
form.deliverycategory
? reverseLogisticsAllowed(form.deliverycategory)
? 'Reverse logistics: available — undelivered parcels can be returned'
: 'Reverse logistics: not available — a returned meal cannot be restocked or resold'
: 'Not recorded for this client. Choosing one also sets whether their parcels can be returned.'
}
>
<select
value={form.deliverycategory}
onChange={(e) => set('deliverycategory')(e.target.value)}
className={inputVariants()}
>
{/* An empty option is kept: clients onboarded before this field
existed have no category, and the edit form must be able to
show that honestly rather than pretending they are General. */}
<option value="">Not recorded</option>
{DELIVERY_CATEGORIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</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,9 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Building2, Copy, Eye, EyeOff, KeyRound, MapPin, Pencil, ShieldCheck, Trash2, UserPlus } from 'lucide-react';
import {
ArrowLeft, Ban, Building2, Copy, Eye, EyeOff, KeyRound, MapPin,
Pencil, RotateCcw, ShieldCheck, Trash2, UserPlus
} from 'lucide-react';
import {
Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
BreadcrumbSeparator, Button, DataTable, EmptyState, Field, Input, PageHeader, Stack,
@@ -11,10 +14,7 @@ import { useAuth } from '@/lib/AuthContext';
import { useOnboardClient, useOnboardedClients, useOnboardingCities } from '@/lib/doormileHooks';
import AddressAutocomplete from '@/components/doormile/AddressAutocomplete';
import { EditClientModal, RemoveClientModal } from './ClientLoginDialogs';
import {
addressFromPlace, addressPayload, canOnboardClients, generateClientPassword, hasMapLocation,
onboardingUnavailableMessage, validateOnboarding,
} from '@/lib/clientOnboarding';
import { addressFromPlace, addressPayload, canOnboardClients, generateClientPassword, hasMapLocation, onboardingUnavailableMessage, validateOnboarding, DELIVERY_CATEGORIES, reverseLogisticsAllowed } from '@/lib/clientOnboarding';
/**
* Client onboarding — register a new client and give them a console login.
@@ -36,6 +36,7 @@ const EMPTY = {
applocationid: '',
password: '',
confirm: '',
deliverycategory: '',
requiredeliveryotp: false,
// Main address, saved as the client's primary location. Coordinates come
// only from picking a suggestion.
@@ -54,6 +55,8 @@ const SERVER_FIELD = [
['mobile', 'phone'],
['password', 'password'],
['operating city', 'applocationid'],
['delivers', 'deliverycategory'],
['delivery category', 'deliverycategory'],
['pincode', 'pincode'],
['address', 'address'],
['map location', 'address'],
@@ -198,6 +201,7 @@ export default function ClientOnboarding() {
phone: form.phone.replace(/\D/g, ''),
password,
applocationid: Number(form.applocationid),
deliverycategory: form.deliverycategory,
requiredeliveryotp: form.requiredeliveryotp,
...addressPayload(form),
},
@@ -350,7 +354,13 @@ export default function ClientOnboarding() {
/>
</Field>
<Field label="Login email" required error={errors.email} hint="The client signs in to the console with this">
<Field
label="Login email"
required
error={errors.email}
hint="The client signs in to the console with this"
className="sm:col-span-2"
>
<Input
type="email"
value={form.email}
@@ -377,6 +387,24 @@ export default function ClientOnboarding() {
</select>
</Field>
<Field
label="What do they deliver?"
required
error={errors.deliverycategory}
hint="Sets their pricing category"
>
<select
value={form.deliverycategory}
onChange={(e) => set('deliverycategory')(e.target.value)}
className={inputVariants()}
>
<option value="">Choose what they ship</option>
{DELIVERY_CATEGORIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</Field>
<div className="sm:col-span-2">
<AddressAutocomplete
id="onboard-address"
@@ -467,6 +495,55 @@ export default function ClientOnboarding() {
>
<Switch checked={form.requiredeliveryotp} onCheckedChange={set('requiredeliveryotp')} />
</Field>
{/*
Reverse logistics follows from WHAT they ship, so it is stated
rather than asked — the operator answers the question they can
answer ("what do they deliver?") and sees the consequence.
When it is off, say so rather than hiding the row silently: an
absent control reads as an oversight, and the operator needs to
know this client has no return path before they promise one.
The backend enforces this independently (constants.ReverseLogisticsAllowed,
checked in InitiateConsignmentRTO). This is presentation only.
*/}
{form.deliverycategory && (
<div className="sm:col-span-2">
<div
className={
'flex items-start gap-2.5 rounded-lg border px-3.5 py-3 ' +
(reverseLogisticsAllowed(form.deliverycategory)
? 'border-emerald-200 bg-emerald-50/60'
: 'border-amber-200 bg-amber-50/60')
}
>
{reverseLogisticsAllowed(form.deliverycategory) ? (
<RotateCcw className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
) : (
<Ban className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
)}
<div className="text-sm leading-snug">
<span
className={
'font-medium ' +
(reverseLogisticsAllowed(form.deliverycategory) ? 'text-emerald-900' : 'text-amber-900')
}
>
{reverseLogisticsAllowed(form.deliverycategory)
? 'Reverse logistics available'
: 'No reverse logistics'}
</span>
<span className="text-muted-foreground">
{reverseLogisticsAllowed(form.deliverycategory)
? ' — undelivered parcels can be returned to this client.'
: ' — a returned meal cannot be restocked or resold, so returns stay off for food clients.'}
</span>
</div>
</div>
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-5">