updates on the ui and updates agents
This commit is contained in:
@@ -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 })}`);
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
150
src/lib/assistant/agent/findingReport.js
Normal file
150
src/lib/assistant/agent/findingReport.js
Normal 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();
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user