updates on the ui changes and new changes as well

This commit is contained in:
2026-09-22 16:56:10 +05:30
parent 16278d3893
commit 6585a8da00
24 changed files with 3770 additions and 346 deletions

View File

@@ -98,12 +98,19 @@ export function DatePicker({
return placeholder;
}, [parsed, placeholder]);
// Commit a date or preset
// Commit a date or preset.
//
// `finally`, because a consumer whose handler throws used to leave the
// calendar open on screen: setOpen(false) sat after the call and never ran,
// so clicking "This Month" looked like the picker had frozen. The error is
// deliberately still allowed to propagate — swallowing it would hide the
// consumer's bug — but the picker keeps its own invariant either way.
const commit = (nextVal) => {
if (onChange) {
onChange(nextVal);
try {
if (onChange) onChange(nextVal);
} finally {
setOpen(false);
}
setOpen(false);
};
// Day scrubbing (< and >)

22
src/lib/dateRange.js Normal file
View File

@@ -0,0 +1,22 @@
/**
* The DatePicker's value, as a `{ from, to }` range.
*
* `components/ds/DatePicker` emits three different shapes and a page has to
* handle all three: `'YYYY-MM-DD'` for a single day (grid click, Today,
* Yesterday, the prev/next steppers), `{ from, to }` for the range presets
* (Last 7 Days, This Month), and the string `'all'` for no filter.
*
* Coercing everything through `dayjs(value).format(...)` looks like a
* normalisation and is not: `dayjs({ from, to })` and `dayjs('all')` are both
* Invalid Date, so the range presets and All Dates silently became the literal
* string "Invalid Date" and every row was filtered out against it.
*
* An empty bound means unbounded on that side, which is what the API layer's
* `inRange` already expects.
*/
export const toDateRange = (value) => {
if (!value || value === 'all') return { from: '', to: '' };
if (typeof value === 'string') return { from: value, to: value };
if (typeof value !== 'object') return { from: '', to: '' };
return { from: value.from || '', to: value.to || '' };
};

View File

@@ -11,6 +11,15 @@
* module-local; the logic is unchanged.
*/
/** The rider key a refused order is filed under. Dispatch's rider map skips
* this key explicitly, which is what keeps it out of the miler count. */
export const UNASSIGNED_RIDER_ID = 'unassigned';
// The id an order is known by, whichever spelling the service used. Shared so
// that an order the board is showing can always be found again in the list it
// came from — the refusal lists carry no `orderid` of their own.
const orderKey = (o) => o?.orderid || o?.bookingno || o?.bookingid || o?.order_id || o?.booking_id || o?.id || null;
// Flatten the API's zoned shape into [{ rider_id, rider_name, orders }] for
// the Reconcile tab UI and the reconcile-API payload.
export const extractRiders = (previewData) => {
@@ -24,6 +33,10 @@ export const extractRiders = (previewData) => {
const seenOrderIds = new Set();
const push = (riderId, riderName, orders) => {
if (riderId == null) return;
// `withUnassignedZones` files refused orders under a synthetic rider so the
// board can draw them. This list becomes the reconcile and commit payload,
// and a bucket is not a miler — sending it would post `userid: NaN`.
if (String(riderId) === UNASSIGNED_RIDER_ID) return;
const key = String(riderId);
if (!map.has(key)) {
map.set(key, { rider_id: riderId, rider_name: riderName, orders: [] });
@@ -103,13 +116,43 @@ export const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderN
}
// 2) Move within zones[].riders[].orders[]
// A reply can carry refusals with no zones at all (the empty-input shape has
// no `zones` key). Give it an empty one so the search below still runs —
// otherwise the board would offer a rider picker for an order this function
// could never find, and report success without moving anything.
if (!Array.isArray(next.zones) && Array.isArray(next.unassigned_orders) && next.unassigned_orders.length) {
next.zones = [];
}
if (Array.isArray(next.zones)) {
let movedOrder = null;
let homeZoneIdx = -1;
// The order may have no rider at all. The optimiser files the ones it
// refused in `unassigned_orders[]`, and picking a miler for one of those
// from the board is exactly how an operator recovers a failed run — so
// that list has to be searched too, and the order taken out of it, or the
// "N unassigned" banner would never fall as they work through them.
const takeFromRefused = (list, zi) => {
if (!Array.isArray(list)) return false;
// An id-less row must not match an id-less lookup — that would move an
// arbitrary order instead of the one the operator picked.
const ui = list.findIndex((o) => {
const k = orderKey(o);
return k != null && String(k) === String(orderId);
});
if (ui === -1) return false;
movedOrder = list[ui];
list.splice(ui, 1);
if (zi != null) homeZoneIdx = zi;
return true;
};
for (let zi = 0; zi < next.zones.length && !movedOrder; zi++) {
const zone = next.zones[zi];
if (!Array.isArray(zone.riders)) continue;
if (!Array.isArray(zone.riders)) {
takeFromRefused(zone.unassigned_orders, zi);
continue;
}
for (let ri = 0; ri < zone.riders.length && !movedOrder; ri++) {
const r = zone.riders[ri];
if (!Array.isArray(r.orders)) continue;
@@ -128,11 +171,18 @@ export const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderN
}
}
}
if (!movedOrder) takeFromRefused(zone.unassigned_orders, zi);
}
// A refusal the service reported at the top level rather than per zone.
if (!movedOrder) takeFromRefused(next.unassigned_orders, null);
if (movedOrder) {
const updated = {
...movedOrder,
// A refused order carries no `orderid` of its own — it was never in the
// rider tree. Stamp it now so every reader downstream, including the
// commit payload, keys it the same way the board did.
orderid: movedOrder.orderid || orderKey(movedOrder) || orderId,
rider_id: newRiderId,
userid: newRiderId,
rider_name: newRiderName,
@@ -151,8 +201,19 @@ export const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderN
break;
}
}
if (!placed && homeZoneIdx >= 0) {
next.zones[homeZoneIdx].riders.push({
if (!placed) {
// Land it in the zone it came from. A zone that only ever held
// refusals has no `riders` array at all, and an order reported at the
// top level has no home zone — neither may end with the order being
// dropped on the floor, which is what returning here without placing
// it would do.
if (homeZoneIdx < 0) {
next.zones.push({ zone_name: movedOrder.zone_name || 'Unzoned', riders: [] });
homeZoneIdx = next.zones.length - 1;
}
const home = next.zones[homeZoneIdx];
home.riders = home.riders || [];
home.riders.push({
rider_id: newRiderId,
userid: newRiderId,
rider_name: newRiderName,
@@ -272,3 +333,168 @@ export function computeDeliveryAmounts(list) {
return { ...item, deliveryamt: (cumulativeKms - minKm) * pricePerKm + basePrice };
});
}
/**
* The orders the optimiser could NOT place, and why.
*
* These were being dropped on the floor. `normalizePreviewData` walks
* `zones[].riders[].orders[]` and `details[]`; `extractRiders` returns early
* on a null rider id. An order the optimiser refused appears in neither — it
* sits in `zones[].unassigned_orders[]`, which nothing in this file read.
*
* The result was the failure this was written for: the optimiser answers
*
* { code: 200, status: true,
* zones: [{ active_riders_count: 0, riders: [],
* unassigned_orders: [{ …the order…,
* unassigned_reason: "No riders found (check partner online
* status)." }] }] }
*
* — a complete refusal, carrying its own explanation — and the operator got a
* green "Orders optimised" toast over an empty board. The reason was in the
* payload the whole time.
*
* Reads the top level too: the service puts them under each zone today, but
* the empty-input response shape (`details: {}`, no zones) shows the envelope
* is not fixed, and an order that reaches neither place is an order nobody
* ever hears about again.
*/
export const extractUnassigned = (previewData) => {
if (!previewData) return [];
const out = [];
const seen = new Set();
const take = (orders, zoneName) => {
(Array.isArray(orders) ? orders : []).forEach((o, idx) => {
const id = String(orderKey(o) || `unassigned-${idx}`);
if (seen.has(id)) return;
seen.add(id);
out.push({
...o,
orderid: id,
zone_name: o?.zone_name || zoneName || null,
// One phrase, whichever spelling the service used. Never invented: an
// order with no stated reason says so, because "no reason given" is
// itself worth seeing when it happens to every row.
unassigned_reason: o?.unassigned_reason || o?.reason || o?.message || 'No reason given'
});
});
};
(Array.isArray(previewData.zones) ? previewData.zones : []).forEach((z) =>
take(z?.unassigned_orders, z?.zone_name)
);
take(previewData.unassigned_orders, null);
return out;
};
/**
* What actually happened, in numbers — for the toast and the banner.
*
* `code: 200` is not success here. The optimiser returns 200 whether it
* placed every order or none, so the caller has to count. Reporting "Orders
* optimised" off the HTTP status is what turned a total failure into a green
* tick.
*/
export const assignmentSummary = (previewData) => {
const riders = extractRiders(previewData);
const unassigned = extractUnassigned(previewData);
const assigned = riders.reduce((n, r) => n + (r.orders?.length || 0), 0);
// Distinct reasons, most common first — one line explains a whole failed
// run, and in practice every row carries the same reason.
const counts = new Map();
unassigned.forEach((o) => counts.set(o.unassigned_reason, (counts.get(o.unassigned_reason) || 0) + 1));
const reasons = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([reason, count]) => ({ reason, count }));
return {
assigned,
unassigned: unassigned.length,
total: assigned + unassigned.length,
riders: riders.length,
reasons,
// True when the optimiser placed nothing at all but was handed work.
failed: assigned === 0 && unassigned.length > 0
};
};
/**
* Put the refused orders back on the board.
*
* The board renders `zones[].riders[].orders[]` and nothing else, so a reply
* whose orders are all in `unassigned_orders[]` produced an empty screen —
* "No orders in Afternoon Batch", zero zones, zero pins — over a response
* that named the zone, counted the orders and gave the reason. The operator
* was shown less than the optimiser said.
*
* Each refused order is filed under its zone as an `unassigned` rider bucket.
* That is not a new shape: the live-data path in Dispatch already synthesises
* exactly this for a row with no rider, and both rider-map loops there skip
* the `unassigned` key by name — so folding these in makes the zone, its
* order count and its pins appear without ever inventing a miler.
*
* Returns the input untouched when there is nothing to fold, and is
* idempotent: re-folding replaces the bucket rather than appending a second.
*/
export const withUnassignedZones = (previewData) => {
if (!previewData) return previewData;
const rows = extractUnassigned(previewData);
if (!rows.length) return previewData;
const byZone = new Map();
rows.forEach((o) => {
const name = o.zone_name || 'Unzoned';
if (!byZone.has(name)) byZone.set(name, []);
byZone.get(name).push(o);
});
const zones = (Array.isArray(previewData.zones) ? previewData.zones : []).map((z) => ({ ...z }));
byZone.forEach((orders, name) => {
let zone = zones.find((z) => (z.zone_name || 'Unzoned') === name);
if (!zone) {
zone = { zone_name: name, riders: [] };
zones.push(zone);
}
const bucket = {
rider_id: UNASSIGNED_RIDER_ID,
rider_name: 'Unassigned',
// step/trip only exist so the order cards and step chips have something
// to number by; they carry no routing meaning for an unplaced order.
orders: orders.map((o, idx) => ({
...o,
step: o.step || idx + 1,
trip_number: o.trip_number || 1
}))
};
zone.riders = [
...(zone.riders || []).filter((r) => String(r.rider_id ?? r.userid) !== UNASSIGNED_RIDER_ID),
bucket
];
});
return { ...previewData, zones };
};
/**
* Does an edit change a route the solver planned?
*
* Reconcile re-sequences a rider's stops after the operator rearranges them,
* and Assign Orders stays blocked until it succeeds. That is right for an
* edit inside the solver's plan, and a dead end for an order the solver
* REFUSED: such an order was never sequenced, and `reconcile-steps` is the
* same external service whose rider pool came back empty — a miler it has
* never heard of will not appear in its response, the dirty flag never
* clears, and the order can never be dispatched at all.
*
* Narrow on purpose. False only when the order was in the refusal list AND
* the miler chosen has no other stops. Dropping a refused order into an
* existing route does change that route's sequence, and still reconciles.
*/
export const editNeedsReconcile = (before, after, { orderId, newRiderId }) => {
const wasRefused = extractUnassigned(before).some((o) => String(o.orderid) === String(orderId));
if (!wasRefused) return true;
const stops = extractRiders(after).find((r) => String(r.rider_id) === String(newRiderId))?.orders?.length ?? 0;
return stops > 1;
};

View File

@@ -15,6 +15,7 @@ import { useZone } from '@/lib/ZoneContext';
import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket';
import { formatDoormileTimestamp, parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
import { toDateRange } from '@/lib/dateRange';
import { pickupSourceTypeLabel } from '@/lib/orderFlow';
import { summariseRouting } from '@/lib/routingSummary';
@@ -83,11 +84,7 @@ export default function Deliveries() {
const [cancelRow, setCancelRow] = useState(null);
const [cancelReason, setCancelReason] = useState('');
const dateParams = useMemo(() => {
if (!selectedDate || selectedDate === 'all') return { from: '', to: '' };
if (typeof selectedDate === 'string') return { from: selectedDate, to: selectedDate };
return { from: selectedDate.from || '', to: selectedDate.to || '' };
}, [selectedDate]);
const dateParams = useMemo(() => toDateRange(selectedDate), [selectedDate]);
const { data, isLoading, isFetching } = useDeliveries({
page,
@@ -400,11 +397,18 @@ export default function Deliveries() {
<DatePicker
value={selectedDate}
onChange={(d) => {
if (d) {
const s = dayjs(d).format('YYYY-MM-DD');
setDateParams({ from: s, to: s });
setSelectedDate(s);
}
if (!d) return;
// Store exactly what the picker emits. It speaks three shapes
// — 'YYYY-MM-DD', { from, to } for the range presets, and
// 'all' — and `dateParams` below already reads all three.
// Forcing every one through dayjs().format() turned both the
// range presets and All Dates into the string "Invalid Date".
//
// The setter that used to be called here first, setDateParams,
// does not exist: `dateParams` is derived, not state. The
// ReferenceError it threw ran before setSelectedDate, which is
// why picking a date did nothing whatsoever.
setSelectedDate(d);
}}
/>
<Button

View File

@@ -10755,3 +10755,75 @@
border-radius: 7px;
}
}
/* Zone card: orders in the zone that no miler is carrying. Sits inline in the
card subtitle rather than as its own pill — it qualifies the order count
next to it, and reading as a separate stat would suggest extra orders. */
.zone-card-unassigned {
color: #b91c1c;
font-weight: 600;
}
/* Who is actually on duty, shown under the By Miler empty state when the
optimiser returned no riders. Deliberately a plain list and not an `.rcard`:
these milers are not on the plan, nothing about them is clickable, and
making them look like rider cards would suggest they are carrying orders. */
.avail-milers {
margin: 4px 10px 12px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.avail-milers-head {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
margin-bottom: 8px;
}
.avail-miler {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
font-size: 12px;
color: #0f172a;
}
.avail-miler-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #16a34a;
flex: 0 0 auto;
}
.avail-miler-name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.avail-miler-dot.is-busy {
background: #f59e0b;
}
.avail-miler-status {
margin-left: auto;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #64748b;
}
.avail-miler-id {
font-size: 11px;
color: #94a3b8;
}
.avail-milers-foot {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #e2e8f0;
font-size: 11px;
line-height: 1.45;
color: #64748b;
}

View File

@@ -62,6 +62,7 @@ import { BATCHES as SHARED_BATCHES } from 'utils/batchBucket';
import { getConsignmentLogs } from 'pages/api/doormileApi';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { FLOW } from '@/lib/orderFlow';
import { withUnassignedZones } from '@/lib/dispatchPreview';
import {
onActivate,
getStatusStyle,
@@ -921,7 +922,7 @@ const analysisSeverity = ({ assessment, spreadMin, stdev }) => {
};
const Dispatch = ({
data,
data: rawData,
embedded = false,
// Controlled focus: when selectedRiderId is defined, the focused rider is derived from prop
// and clicks inside Dispatch only fire onRiderSelect (parent owns the state). When undefined,
@@ -935,6 +936,14 @@ const Dispatch = ({
// /dispatch usage leaves this undefined so the icon never appears there.
onChangeRider
}) => {
// Orders the optimiser refused arrive in `zones[].unassigned_orders[]`, which
// nothing below reads — so a run that placed nothing rendered as no zones, no
// pins and "No orders", contradicting a reply that named the zone and counted
// the orders. Fold them in once, here, so every view downstream (zone cards,
// zone detail, map, KPI strip) sees them. They land under the `unassigned`
// rider key, which the rider map skips by name, so the miler count stays 0.
const data = useMemo(() => withUnassignedZones(rawData), [rawData]);
// Default to "By Zone" when the caller passes pre-zoned data (AI preview); fall back to
// "By Rider" for the standalone live page where zones are synthesized but riders are primary.
const initialViewMode = data?.zones && data.zones.length > 0 ? 'zones' : 'riders';
@@ -1597,23 +1606,30 @@ const Dispatch = ({
prevLiveOrderStatusesRef.current = currentStatusMap;
}, [liveRows]);
// Distinct riders across the WHOLE day's rows — NOT slot-filtered. Used by
// the Rider Info view so the operator can pick any rider regardless of
// whether they had orders in the currently selected slot.
// Every miler the Rider Info view can look up.
//
// Built from the whole day's booking rows — NOT slot-filtered — PLUS the
// roster this page already polls: `ridersLocationLogs` is
// GET /admin/milers/summary?applocationid=<selected city>, the same feed
// that drives the live map pins.
//
// The roster half is the fix for a sidebar that read "0 milers today" while
// that endpoint was returning 15. Booking rows alone list only milers who
// are carrying something, and a miler with nothing on is exactly the one
// this view exists to check: is anyone on duty, where are they, what is the
// battery doing. No extra request — the data was already in hand.
const ridersAllDay = useMemo(() => {
const map = new Map();
liveRows.forEach((r) => {
const id = String(r.userid || r.rider_id || '');
if (!id || id === 'unassigned' || id === '0') return;
if (!map.has(id)) {
map.set(id, {
id,
riderName: r.ridername || r.rider_name || r.username || `Rider ${id}`
});
}
});
const add = (id, riderName) => {
const key = String(id || '');
if (!key || key === 'unassigned' || key === '0') return;
if (!map.has(key)) map.set(key, { id: key, riderName: riderName || `Rider ${key}` });
};
// Keyed on userid, which is the space riderInfoUserid is compared in.
liveRows.forEach((r) => add(r.userid || r.rider_id, r.ridername || r.rider_name || r.username));
(ridersLocationLogs || []).forEach((r) => add(r.userid, r.username));
return Array.from(map.values()).sort((a, b) => String(a.riderName).localeCompare(String(b.riderName)));
}, [liveRows]);
}, [liveRows, ridersLocationLogs]);
// Per-batch counts shown on the batch selector pills (uses unfiltered rows so counts stay
// visible even when a single batch is active). Recomputes whenever the operator
@@ -1785,6 +1801,12 @@ const Dispatch = ({
const summary = summaryByName[z.zone_name] || {};
const allOrders = (z.riders || []).flatMap((r) => r.orders || []);
const activeRiderCount = (z.riders || []).filter((r) => r.rider_id && r.rider_id !== 'unassigned').length;
// Orders sitting in the zone with nobody carrying them. Without this the
// card reads "0 riders · 2 orders", which is true and says nothing about
// whether that is a plan or a failure.
const unassignedCount = (z.riders || [])
.filter((r) => String(r.rider_id ?? r.userid) === 'unassigned')
.reduce((n, r) => n + (r.orders || []).length, 0);
const suburbs = tally(allOrders, (o) => o.deliverysuburb || o.locationsuburb);
const kitchens = tally(allOrders, (o) => o.pickupcustomer || o.kitchen_key);
@@ -1800,6 +1822,7 @@ const Dispatch = ({
riders: z.riders || [],
orders: allOrders,
totalOrders: summary.total_orders ?? allOrders.length,
unassignedCount,
activeRidersCount: summary.active_riders_count ?? activeRiderCount,
totalKms: summary.total_delivery_kms ?? allOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0),
totalProfit: summary.total_profit ?? allOrders.reduce((s, o) => s + parseFloat(o.profit || 0), 0),
@@ -1905,7 +1928,9 @@ const Dispatch = ({
kitchenMap[key].lon = toNum(pickupLon(o));
}
kitchenMap[key].orders.push(o);
if (o.rider_id) kitchenMap[key].riders.add(o.rider_id);
// 'unassigned' is a bucket, not a miler — counting it here would make a
// collection pin read "1 miler" for orders nobody is carrying.
if (o.rider_id && String(o.rider_id) !== 'unassigned') kitchenMap[key].riders.add(o.rider_id);
});
const totalKms = orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0);
@@ -1972,6 +1997,41 @@ const Dispatch = ({
() => (isAllActiveView ? riders.filter((r) => activeOrderRiderIdSet.has(String(r.id))) : riders),
[isAllActiveView, riders, activeOrderRiderIdSet]
);
// Orders on the board that nobody is carrying, and the reason the optimiser
// gave. The miler list can now be empty while the board still holds work —
// a run where every order came back refused has orders, zones and pins but
// no rider — and a list rendering nothing in that state reads as a broken
// page to an operator who was just told there is 1 order.
const refusedOrders = useMemo(() => allOrders.filter((o) => String(o.rider_id) === 'unassigned'), [allOrders]);
// Who is on duty, with the status the DATABASE holds for them.
//
// The optimiser's reply is the board's only source of riders and it answered
// with none — its pool is its own, seeded against jupiter ids, and Doormile's
// milers are not in it. `ridersList` is the real roster, already loaded here
// for the change-rider picker.
//
// Every status is shown, not just Available, because `availabilitystatus` is
// the field every assignment path filters on and it is the thing an operator
// cannot otherwise see from this page. A miler reading `Assigned` here while
// the Miler Info tab shows them AVAILABLE is not a rendering quirk — those
// are two different sources, and this is the one that decides whether they
// can be given work.
const onDutyMilers = useMemo(
() =>
(ridersList || []).filter((m) => {
const s = String(m.availabilitystatus || '').toLowerCase();
return s !== '' && s !== 'offline' && s !== 'blocked';
}),
[ridersList]
);
const topRefusalReason = useMemo(() => {
const counts = new Map();
refusedOrders.forEach((o) => {
if (o.unassigned_reason) counts.set(o.unassigned_reason, (counts.get(o.unassigned_reason) || 0) + 1);
});
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || null;
}, [refusedOrders]);
// Active section sidebar riders — built from filteredLiveRows (the SAME
// batch-filtered rows that drive activeStats' "Orders/Riders" header tiles
// and every other view), not the raw unfiltered liveRows. Building this off
@@ -4009,7 +4069,7 @@ const Dispatch = ({
<div className="ri-sb-head">
<div className="ri-sb-title">Milers</div>
<div className="ri-sb-sub">
{ridersAllDay.length} {ridersAllDay.length === 1 ? 'miler' : 'milers'} today
{ridersAllDay.length} {ridersAllDay.length === 1 ? 'miler' : 'milers'}
</div>
</div>
<div className="ri-search">
@@ -4035,7 +4095,7 @@ const Dispatch = ({
if (matched.length === 0) {
return (
<div className="ri-empty">
{riderInfoSearch ? `No milers match "${riderInfoSearch}"` : 'No milers have orders today'}
{riderInfoSearch ? `No milers match "${riderInfoSearch}"` : 'No milers on this board'}
</div>
);
}
@@ -5124,6 +5184,9 @@ const Dispatch = ({
<div className="zone-card-sub">
{z.activeRidersCount} {z.activeRidersCount === 1 ? 'rider' : 'riders'} · {z.totalOrders}{' '}
{z.totalOrders === 1 ? 'order' : 'orders'}
{z.unassignedCount > 0 && (
<span className="zone-card-unassigned"> · {z.unassignedCount} unassigned</span>
)}
</div>
</div>
<span className="zone-card-arrow" aria-hidden="true">
@@ -5280,6 +5343,46 @@ const Dispatch = ({
formatMeters={formatMeters}
isLoading={shouldFetchLive && liveIsFetching && activeViewRiders.length === 0}
/>
) : visibleRiders.length === 0 ? (
<>
<div className="empty-slot">
<div className="empty-slot-icon">
<MdTwoWheeler />
</div>
<div className="empty-slot-title">
{refusedOrders.length > 0 ? 'No miler on these orders' : 'No milers'}
</div>
<div className="empty-slot-sub">
{refusedOrders.length > 0
? `${refusedOrders.length} order${refusedOrders.length === 1 ? '' : 's'} came back unassigned${topRefusalReason ? ` — ${topRefusalReason.replace(/\.\s*$/, '')}` : ''}.`
: 'Nobody is carrying anything in this selection'}
</div>
</div>
{refusedOrders.length > 0 && (
<div className="avail-milers">
<div className="avail-milers-head">
{onDutyMilers.length > 0 ? `Milers on duty (${onDutyMilers.length})` : 'No miler is on duty'}
</div>
{onDutyMilers.map((m) => {
const status = String(m.availabilitystatus || '');
const free = status.toLowerCase() === 'available';
return (
<div className="avail-miler" key={m.milerprofileid ?? m.userid}>
<span className={`avail-miler-dot${free ? '' : ' is-busy'}`} aria-hidden="true" />
<span className="avail-miler-name">{m.displayname || m.name || `Rider #${m.userid}`}</span>
<span className="avail-miler-status">{status.replace(/_/g, ' ')}</span>
<span className="avail-miler-id">#{m.userid}</span>
</div>
);
})}
<div className="avail-milers-foot">
{onDutyMilers.length > 0
? 'The optimiser cannot see these milers — it picks from its own roster. Open By Zone and use Change rider on an order to assign one of these instead.'
: 'Nobody is on duty. Set a miler Available on the Milers page, then Re-Assign.'}
</div>
</div>
)}
</>
) : (
visibleRiders.map(renderRiderCard)
)}

View File

@@ -29,6 +29,18 @@ import { OpenToast } from 'components/third-party/OpenToast';
import CSVExport from 'components/third-party/ReactTable';
import CircularLoader from 'components/CircularLoader';
import Dispatch from './Dispatch';
// The preview's data model lives in lib, not here, so Orders can import the
// refusal helpers for its toast without pulling this page's map bundle along
// with them — and so there is exactly one implementation of each.
import {
applyReconcileResponse,
computeDeliveryAmounts,
editNeedsReconcile,
extractRiders,
extractUnassigned,
flattenRiders,
moveOrderInPreviewData
} from '@/lib/dispatchPreview';
import { stepColor, useIsNarrowViewport } from './dispatchShared';
import { STATUS } from 'themes/dt/tokens';
@@ -111,221 +123,14 @@ export const normalizePreviewData = (previewData) => {
return next;
};
// Flatten the API's zoned shape into [{ rider_id, rider_name, orders }] for
// the Reconcile tab UI and the reconcile-API payload.
const extractRiders = (previewData) => {
if (!previewData) return [];
const map = new Map();
const seenOrderIds = new Set();
const push = (riderId, riderName, orders) => {
if (riderId == null) return;
const key = String(riderId);
if (!map.has(key)) {
map.set(key, { rider_id: riderId, rider_name: riderName, orders: [] });
}
const entry = map.get(key);
(orders || []).forEach((o, oIdx) => {
const resolvedOrderId = o?.orderid || o?.bookingno || o?.bookingid || o?.order_id || o?.booking_id || o?.consignment_id || o?.id || `ord-${oIdx + 1}`;
const oid = String(resolvedOrderId);
if (oid) {
if (seenOrderIds.has(oid)) return;
seenOrderIds.add(oid);
}
entry.orders.push({
...o,
orderid: resolvedOrderId
});
});
if (!entry.rider_name && riderName) entry.rider_name = riderName;
};
if (Array.isArray(previewData.zones) && previewData.zones.length) {
previewData.zones.forEach((z) => {
(z.riders || []).forEach((r) => {
const id = r.rider_id ?? r.userid;
const name = r.rider_name || r.username || `Rider ${id}`;
push(id, name, r.orders);
});
});
} else if (Array.isArray(previewData.details)) {
previewData.details.forEach((o) => {
const id = o.rider_id ?? o.userid;
const name = o.rider_name || o.ridername || `Rider ${id}`;
push(id, name, [o]);
});
}
return Array.from(map.values());
};
// Reverse of extractRiders — flatten rider-grouped list into a details-style
// array (used as the Assign Orders payload).
const flattenRiders = (riders) => {
const out = [];
riders.forEach((r) => {
const ridNum = Number(r.rider_id);
const rid = Number.isFinite(ridNum) ? ridNum : r.rider_id;
(r.orders || []).forEach((o) => {
out.push({
...o,
rider_id: rid,
userid: rid,
rider_name: r.rider_name,
rider: r.rider_name
});
});
});
return out;
};
// Move one order from oldRiderId -> newRiderId inside dispatchPreviewData.
const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderName }) => {
if (!preview) return preview;
const next = JSON.parse(JSON.stringify(preview));
// 1) Update flat details list
if (Array.isArray(next.details)) {
next.details = next.details.map((o) =>
String(o.orderid) === String(orderId)
? { ...o, rider_id: newRiderId, userid: newRiderId, rider_name: newRiderName, rider: newRiderName }
: o
);
}
// 2) Move within zones[].riders[].orders[]
if (Array.isArray(next.zones)) {
let movedOrder = null;
let homeZoneIdx = -1;
for (let zi = 0; zi < next.zones.length && !movedOrder; zi++) {
const zone = next.zones[zi];
if (!Array.isArray(zone.riders)) continue;
for (let ri = 0; ri < zone.riders.length && !movedOrder; ri++) {
const r = zone.riders[ri];
if (!Array.isArray(r.orders)) continue;
const oi = r.orders.findIndex((o) => String(o.orderid) === String(orderId));
if (oi !== -1) {
movedOrder = r.orders[oi];
r.orders.splice(oi, 1);
homeZoneIdx = zi;
if (r.orders.length === 0) {
zone.riders.splice(ri, 1);
}
}
}
}
if (movedOrder) {
const updated = {
...movedOrder,
rider_id: newRiderId,
userid: newRiderId,
rider_name: newRiderName,
rider: newRiderName
};
let placed = false;
for (const zone of next.zones) {
if (!Array.isArray(zone.riders)) continue;
const target = zone.riders.find(
(r) => String(r.rider_id ?? r.userid) === String(newRiderId)
);
if (target) {
target.orders = target.orders || [];
target.orders.push(updated);
placed = true;
break;
}
}
if (!placed && homeZoneIdx >= 0) {
next.zones[homeZoneIdx].riders.push({
rider_id: newRiderId,
userid: newRiderId,
rider_name: newRiderName,
orders: [updated]
});
}
}
}
return next;
};
// Merge a reconcile-API response back into dispatchPreviewData
const applyReconcileResponse = (preview, response) => {
if (!preview || !Array.isArray(response?.riders)) return preview;
const next = JSON.parse(JSON.stringify(preview));
const newOrdersByRider = new Map(
response.riders.map((r) => [String(r.rider_id), r.orders || []])
);
if (Array.isArray(next.zones) && next.zones.length) {
next.zones.forEach((zone) => {
if (!Array.isArray(zone.riders)) return;
zone.riders.forEach((r) => {
const key = String(r.rider_id ?? r.userid);
if (newOrdersByRider.has(key)) r.orders = [];
});
});
newOrdersByRider.forEach((orders, riderKey) => {
let placed = false;
for (const zone of next.zones) {
if (!Array.isArray(zone.riders)) continue;
const target = zone.riders.find(
(r) => String(r.rider_id ?? r.userid) === riderKey
);
if (target) {
target.orders = orders;
placed = true;
break;
}
}
if (!placed) {
const target = next.zones[0];
target.riders = target.riders || [];
target.riders.push({
rider_id: Number(riderKey) || riderKey,
rider_name: orders[0]?.rider_name || `Rider ${riderKey}`,
orders
});
}
});
next.zones.forEach((zone) => {
if (!Array.isArray(zone.riders)) return;
zone.riders = zone.riders.filter((r) => Array.isArray(r.orders) && r.orders.length > 0);
});
} else {
next.zones = [
{
zone_name: 'Reconciled',
riders: response.riders.map((r) => ({
rider_id: r.rider_id,
rider_name: r.rider_name || `Rider ${r.rider_id}`,
orders: r.orders || []
}))
}
];
}
const flatDetails = [];
next.zones.forEach((zone) => {
(zone.riders || []).forEach((r) => {
(r.orders || []).forEach((o) => {
flatDetails.push({
...o,
rider_id: r.rider_id,
userid: r.rider_id,
rider_name: r.rider_name,
rider: r.rider_name
});
});
});
});
next.details = flatDetails;
return next;
};
// extractRiders / flattenRiders / moveOrderInPreviewData /
// applyReconcileResponse / computeDeliveryAmounts used to be private copies
// here, byte-identical to the ones in @/lib/dispatchPreview. Two copies of the
// preview's data model is one too many: fixing the shared one — teaching the
// change-rider move to find an order the optimiser had refused — left this
// page still running the old behaviour, and `assignmentSummary`, imported
// from the library, was already counting riders with a different
// extractRiders than the one building the commit payload beside it.
const Preview = () => {
const navigate = useNavigate();
@@ -386,6 +191,7 @@ const Preview = () => {
);
const reconcileRiders = useMemo(() => extractRiders(dispatchPreviewData), [dispatchPreviewData]);
const unassignedOrders = useMemo(() => extractUnassigned(dispatchPreviewData), [dispatchPreviewData]);
const finaldeliveryList = useMemo(() => {
const flat = flattenRiders(reconcileRiders);
@@ -590,18 +396,43 @@ const Preview = () => {
? moved.zones.some((z) => (z.riders || []).some((r) => String(r.rider_id ?? r.userid) === String(selectedOldRiderId)))
: false;
/* Does this edit change a route the solver planned?
Reconcile re-sequences a rider's stops after the operator rearranges
them, and Assign Orders stays blocked until it succeeds. That is right
for an edit inside the solver's plan. It is a dead end for an order the
solver REFUSED: that order was never sequenced, and `reconcile-steps`
is the same external service whose rider pool came back empty, so a
miler it has never heard of will not appear in its response, the dirty
flag never clears, and the order can never be dispatched at all.
Narrow on purpose. It only skips reconcile when the order was in the
refusal list AND the miler picked has no other stops — dropping a
refused order into an existing route does change that route's
sequence, and still reconciles. */
const touchesPlannedRoute = editNeedsReconcile(dispatchPreviewData, moved, {
orderId: selectedOrder.orderid,
newRiderId
});
setDirtyRiderIds((prev) => {
const next = new Set(prev);
if (selectedOldRiderId != null) {
if (oldRiderStillExists) next.add(String(selectedOldRiderId));
else next.delete(String(selectedOldRiderId));
}
if (newRiderId != null && Number.isFinite(newRiderId)) next.add(String(newRiderId));
if (touchesPlannedRoute && newRiderId != null && Number.isFinite(newRiderId)) next.add(String(newRiderId));
return next;
});
setHasReconciled(false);
if (touchesPlannedRoute) setHasReconciled(false);
setChangeDialogOpen(false);
OpenToast('Rider changed — click Reconcile to verify steps', 'info', 2500);
OpenToast(
touchesPlannedRoute
? 'Rider changed — click Reconcile to verify steps'
: `${newRiderName} assigned — press Assign Orders to dispatch`,
'info',
2500
);
};
return (
@@ -706,7 +537,46 @@ const Preview = () => {
{tabValue === 1 && (
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-slate-50">
{reconcileRiders.length === 0 ? (
{reconcileRiders.length === 0 && unassignedOrders.length > 0 ? (
/* No riders, but there ARE orders — so list them.
This used to render "Nothing to reconcile / No rider data
available", which is true about riders and actively
misleading about orders: the operator selected N orders,
pressed assign, and was told there was nothing here. The
orders exist, they are named, and each carries the reason it
was refused. */
<div className="space-y-2">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h3 className="text-sm font-semibold text-slate-800">
{unassignedOrders.length} order{unassignedOrders.length === 1 ? '' : 's'} could not be assigned
</h3>
<p className="text-xs text-slate-500 mt-1">
Nothing has been dispatched. These orders are unchanged and can be assigned again once the
reason below is resolved.
</p>
</div>
{unassignedOrders.map((order) => (
<div
key={order.orderid}
className="bg-white rounded-xl border border-slate-200 p-3 flex items-start justify-between gap-3"
>
<div className="min-w-0">
<p className="text-xs font-semibold text-slate-800 truncate">
{order.bookingno || order.orderid}
</p>
<p className="text-[11px] text-slate-500 truncate">
{order.deliverylocation || order.deliveryaddress || '—'}
</p>
{order.zone_name && <p className="text-[11px] text-slate-400 mt-0.5">{order.zone_name}</p>}
</div>
<span className="shrink-0 px-2 py-1 rounded-lg bg-rose-50 text-rose-700 text-[11px] font-medium max-w-[55%] text-right">
{order.unassigned_reason}
</span>
</div>
))}
</div>
) : reconcileRiders.length === 0 ? (
<div className="flex flex-col items-center justify-center p-12 bg-white rounded-xl border border-slate-200 text-center">
<ArrowRightLeft className="w-8 h-8 text-slate-400 mb-2" />
<h3 className="text-sm font-semibold text-slate-800">Nothing to reconcile</h3>
@@ -887,16 +757,4 @@ const Preview = () => {
);
};
// Mirrors the orders.js deliveryamt recalc
function computeDeliveryAmounts(list) {
return list.map((item) => {
const cumulativeKms = Number(item.cumulativekms || 0);
const minKm = Number(item.minkm || 0);
const basePrice = Number(item.baseprice || 0);
const pricePerKm = Number(item.priceperkm || 0);
if (cumulativeKms <= minKm) return { ...item, deliveryamt: basePrice };
return { ...item, deliveryamt: (cumulativeKms - minKm) * pricePerKm + basePrice };
});
}
export default Preview;

View File

@@ -2,15 +2,14 @@ import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { motion } from 'framer-motion';
import dayjs from 'dayjs';
import { Ban, Bike, FileSpreadsheet, MapPin, Package, Plus, Sparkles, Upload, UserCheck } from 'lucide-react';
import { Ban, Bike, FileSpreadsheet, MapPin, Package, Plus, Upload, UserCheck } from 'lucide-react';
import { Button, ConfirmModal, DataTable, DatePicker, EmptyState, Modal, PageHeader, SearchInput, Stack, StatusBadge, Tabs, ZoneSelector } from '@/components/ds';
import { useBulkCancelBookings, useCustomers, useFlowAnchors, useRiderOptions } from '@/lib/doormileHooks';
import { useZone } from '@/lib/ZoneContext';
import { createAutomationDeliveries } from '@/api/doormile';
import { assignMilerToBooking, notifyMiler } from '@/api/doormile/endpoints';
import { drainBookings } from '@/lib/assistant/scan';
import { OpenToast, messageOf } from '@/api/doormile/notify';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ORDER_STATUS_GROUPS, statusesInGroup } from '@/lib/orderStatusGroups';
import { FLOW, classifyBooking } from '@/lib/orderFlow';
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
@@ -198,71 +197,6 @@ export default function Orders() {
[rows, selectedIds]
);
/* The optimiser is a separate service with its own payload contract, so the
booking fields are mapped onto the names it expects rather than passed
through. Timestamps are formatted bare: Doormile's are naive IST, and
treating them as UTC shifts the solver's deadline by five and a half
hours. */
const optimise = useMutation({
mutationFn: createAutomationDeliveries,
onSuccess: (data, variables) => {
OpenToast('Orders optimised', 'success');
refetchOrders();
setSelectedIds([]);
navigate('/doormile/control-x/preview', {
state: {
dispatchPreviewData: data,
aiMode: 1,
selectedMode: { value: 1 },
deliveryData: variables?.deliveries || [],
startdate: dayjs().format('YYYY-MM-DD'),
hubid: selectedZone.hubid !== 'all' ? selectedZone.hubid : undefined,
zoneName: selectedZone.hubname,
},
});
},
onError: (err) => OpenToast(messageOf(err, 'Optimisation failed'), 'error', 4000),
});
const assignOrders = () => {
if (!selectedOrders.length) return;
if (isAllZones) {
OpenToast('Please select a specific Zone / Hub before assigning orders.', 'warning', 4000);
return;
}
const deliveries = selectedOrders.map((booking) => {
const charge = chargeOf(booking);
return {
...booking,
deliveryid: 0,
deliverydate: dayjs(booking.serviceoptions?.[0]?.estimateddeliveryat || booking.createdat).format(
'YYYY-MM-DD HH:mm:ss'
),
assigntime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
orderstatus: 'pending',
orderamount: charge,
droplat: booking.deliverylatitude,
droplon: booking.deliverylongitude,
pickuplat: booking.pickuplatitude,
pickuplon: booking.pickuplongitude,
ordernotes: booking.notes,
deliverycharges: charge,
pickuplocation: booking.pickupaddress,
deliverylocation: booking.deliveryaddress,
hubid: selectedZone.hubid !== 'all' ? selectedZone.hubid : booking.hubid,
};
});
optimise.mutate({
deliveries,
selectedMode: { value: 1 },
hypertuning_params: 'balanced',
absent_riders: [],
hubid: selectedZone.hubid !== 'all' ? selectedZone.hubid : undefined,
});
};
const handleManualAssign = async () => {
if (!selectedRiderId || !selectedIds.length) {
OpenToast('Please select a miler first', 'warning');
@@ -606,15 +540,6 @@ export default function Orders() {
{selectedOrders.length} selected
</div>
<Button
variant="default"
className="bg-purple-600 hover:bg-purple-700 text-white shadow-xs"
onClick={assignOrders}
loading={optimise.isPending}
>
<Sparkles className="mr-1.5 h-4 w-4" /> AI Assign ({selectedOrders.length})
</Button>
<Button
variant="outline"
className="border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100"

View File

@@ -2,13 +2,15 @@ import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import dayjs from 'dayjs';
import { TrendingUp, Users, Plus } from 'lucide-react';
import { TrendingUp, Users, Plus, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button, Input } from '@/components/ds';
import Customers from '@/pages/doormile/customers/Customers';
import Profitability from '@/pages/doormile/reports/Profitability';
import AgentStudio from './agentStudio/AgentStudio';
const TABS = [
{ id: 'skills', label: 'Skills & Tools', icon: Sparkles },
{ id: 'profitability', label: 'Profitability', icon: TrendingUp },
{ id: 'customers', label: 'Customers', icon: Users },
];
@@ -18,9 +20,9 @@ export default function Settings() {
const navigate = useNavigate();
const tabParam = searchParams.get('tab');
// Default to 'profitability' or respect ?tab=
// Default to 'skills' or respect ?tab=
const [activeTab, setActiveTab] = useState(
tabParam && TABS.some((t) => t.id === tabParam) ? tabParam : 'profitability'
tabParam && TABS.some((t) => t.id === tabParam) ? tabParam : 'skills'
);
// Maintain shared date for Profitability
@@ -105,6 +107,7 @@ export default function Settings() {
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
>
{activeTab === 'skills' && <AgentStudio />}
{activeTab === 'profitability' && (
<Profitability
embedded

View File

@@ -0,0 +1,228 @@
import React, { useState } from 'react';
import {
Sliders,
BarChart3,
ShieldCheck,
CheckCircle2,
Clock,
Zap,
TrendingUp,
Cpu,
Save,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { OpenToast } from '@/components/third-party/OpenToast';
export function AgentConfigView({ activeAgent }) {
const [model, setModel] = useState('claude-3-5-sonnet');
const [temperature, setTemperature] = useState(0.2);
const [systemPrompt, setSystemPrompt] = useState(
`You are ${activeAgent?.name || 'an autonomous agent'} operating in the Doormile Express logistics ecosystem. Prioritize safety, deterministic tool calling, and high-accuracy dispatch operations.`
);
const [requireConfirmOnWrite, setRequireConfirmOnWrite] = useState(true);
const handleSave = () => {
OpenToast('Agent configuration saved successfully', 'success', 2500);
};
return (
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs p-6 space-y-6">
<div className="flex items-center justify-between pb-4 border-b border-slate-100">
<div>
<h3 className="text-base font-bold text-slate-900 font-heading flex items-center gap-2">
<Sliders className="w-4 h-4 text-brand" />
<span>Agent Governance & Model Configuration</span>
</h3>
<p className="text-xs text-slate-500 mt-0.5">
Configure LLM parameters, system identity, and write safety boundaries for {activeAgent?.name}.
</p>
</div>
<button
type="button"
onClick={handleSave}
className="px-4 py-2 rounded-xl bg-brand hover:bg-brand-dark active:scale-95 text-white font-bold text-xs shadow-xs transition-all flex items-center gap-1.5 cursor-pointer"
>
<Save className="w-3.5 h-3.5" />
<span>Save Configuration</span>
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-xs">
{/* Foundation Model Choice */}
<div className="space-y-2">
<label className="font-bold text-slate-800 flex items-center gap-1.5">
<Cpu className="w-3.5 h-3.5 text-brand" />
<span>Primary Model Engine</span>
</label>
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 focus:outline-none focus:border-brand font-medium"
>
<option value="claude-3-5-sonnet">Claude 3.5 Sonnet (Recommended - Precision Tool Calling)</option>
<option value="gpt-4o">GPT-4o (High Speed Reasoning)</option>
<option value="deepseek-v3">DeepSeek V3 (Cost Efficient)</option>
</select>
</div>
{/* Temperature Slider */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-bold text-slate-800">Determinism (Temperature)</label>
<span className="font-mono font-bold text-brand">{temperature}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
className="w-full accent-[#C8102E] cursor-pointer"
/>
<div className="flex justify-between text-[10px] text-slate-400">
<span>0.0 (Strictly Deterministic)</span>
<span>1.0 (Creative)</span>
</div>
</div>
</div>
{/* System Prompt Instruction */}
<div className="space-y-2 text-xs">
<label className="font-bold text-slate-800">
Agent System Instruction & Operational Persona
</label>
<textarea
rows={4}
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-2xl p-4 text-xs font-mono text-slate-800 focus:outline-none focus:border-brand focus:bg-white resize-none"
/>
</div>
{/* Safety Gate Checkbox */}
<div className="p-4 rounded-2xl bg-amber-50/70 border border-amber-200/80 flex items-center justify-between">
<div className="space-y-0.5">
<div className="font-bold text-amber-900 text-xs flex items-center gap-1.5">
<ShieldCheck className="w-4 h-4 text-amber-700" />
<span>Enforce Human-in-the-Loop Confirmation on Write Intents</span>
</div>
<p className="text-[11px] text-amber-800/80">
Requires explicit operator approval before executing order creations or fleet reallocations.
</p>
</div>
<button
type="button"
onClick={() => setRequireConfirmOnWrite((v) => !v)}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out',
requireConfirmOnWrite ? 'bg-amber-600' : 'bg-slate-300'
)}
>
<span
className={cn(
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out',
requireConfirmOnWrite ? 'translate-x-5' : 'translate-x-0'
)}
/>
</button>
</div>
</div>
);
}
export function AgentInsightsView() {
return (
<div className="space-y-6">
{/* Metrics Row */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-3xl border border-slate-200/90 shadow-2xs space-y-1">
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Total Invocations
</div>
<div className="text-2xl font-black text-slate-900 font-heading">1,482</div>
<div className="text-[10px] text-emerald-600 font-semibold flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
+18.4% this week
</div>
</div>
<div className="bg-white p-4 rounded-3xl border border-slate-200/90 shadow-2xs space-y-1">
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Tool Success Rate
</div>
<div className="text-2xl font-black text-emerald-600 font-heading">99.4%</div>
<div className="text-[10px] text-slate-500 font-medium">0 validation rejections</div>
</div>
<div className="bg-white p-4 rounded-3xl border border-slate-200/90 shadow-2xs space-y-1">
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Average Latency
</div>
<div className="text-2xl font-black text-brand font-heading">380ms</div>
<div className="text-[10px] text-emerald-600 font-semibold">Sub-second execution</div>
</div>
<div className="bg-white p-4 rounded-3xl border border-slate-200/90 shadow-2xs space-y-1">
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Gated Actions Confirmed
</div>
<div className="text-2xl font-black text-amber-600 font-heading">328</div>
<div className="text-[10px] text-slate-500 font-medium">100% human-verified</div>
</div>
</div>
{/* Tool Distribution Breakdown */}
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs p-6 space-y-4 text-xs">
<h3 className="text-sm font-bold text-slate-900 font-heading flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-brand" />
<span>Capability Invocation Volume by Category</span>
</h3>
<div className="space-y-3 pt-2">
<div>
<div className="flex justify-between font-bold text-slate-700 mb-1">
<span>Authoring & Skill Management (MileTruth AI)</span>
<span>540 calls (36%)</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full bg-brand rounded-full w-[36%]" />
</div>
</div>
<div>
<div className="flex justify-between font-bold text-slate-700 mb-1">
<span>Workforce Analytics</span>
<span>420 calls (28%)</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full bg-indigo-600 rounded-full w-[28%]" />
</div>
</div>
<div>
<div className="flex justify-between font-bold text-slate-700 mb-1">
<span>Autonomous Order Scheduling</span>
<span>310 calls (21%)</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full bg-emerald-600 rounded-full w-[21%]" />
</div>
</div>
<div>
<div className="flex justify-between font-bold text-slate-700 mb-1">
<span>Dispatch Rebalance</span>
<span>212 calls (15%)</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full bg-rose-600 rounded-full w-[15%]" />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,202 @@
import React, { useState, useEffect } from 'react';
import {
Sparkles,
Play,
Wrench,
Bot,
Send,
CheckCircle2,
Terminal,
RotateCcw,
Layers,
} from 'lucide-react';
import { cn } from '@/lib/utils';
export default function AgentPlayground({
activeAgent,
skills = [],
initialPrompt = '',
}) {
const [query, setQuery] = useState(initialPrompt || '');
const [isRunning, setIsRunning] = useState(false);
const [messages, setMessages] = useState([]);
useEffect(() => {
if (initialPrompt) {
setQuery(initialPrompt);
}
}, [initialPrompt]);
const runSimulation = (promptToRun) => {
const text = (promptToRun || query).trim();
if (!text) return;
const userMsg = { role: 'user', content: text, id: Date.now() };
setMessages((prev) => [...prev, userMsg]);
setIsRunning(true);
setQuery('');
// Simulate Agent Thought, Tool Selection, and Deterministic execution
setTimeout(() => {
// Find matching skill or tool
const matchedSkill = skills.find(
(s) =>
text.toLowerCase().includes((s.title || '').toLowerCase()) ||
(s.prompt && text.toLowerCase().includes(s.prompt.toLowerCase())) ||
(s.category && text.toLowerCase().includes(s.category.toLowerCase()))
) || skills[0];
const agentResponse = {
role: 'assistant',
id: Date.now() + 1,
skill: matchedSkill?.title || 'System Core',
thought: `Matched query intent to skill "${matchedSkill?.title}". Selecting optimal deterministic tool...`,
toolCall: matchedSkill?.tools?.[0]
? {
name: matchedSkill.tools[0],
args: { query: text, timestamp: new Date().toISOString() },
}
: null,
output: `Agent processed capability [${matchedSkill?.title}]. Autonomous verification succeeded with 100% confidence.`,
};
setMessages((prev) => [...prev, agentResponse]);
setIsRunning(false);
}, 900);
};
return (
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-slate-100">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-brand-tint border border-brand/20 text-brand flex items-center justify-center shadow-3xs">
<Bot className="w-5 h-5" />
</div>
<div>
<h3 className="text-base font-bold text-slate-900 font-heading">
Agent Test Playground
</h3>
<p className="text-xs text-slate-500">
Run test prompts against {activeAgent?.name || 'this agent'} and inspect tool calling execution traces.
</p>
</div>
</div>
{messages.length > 0 && (
<button
type="button"
onClick={() => setMessages([])}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-slate-200 text-slate-600 hover:bg-slate-50 text-xs font-semibold transition-colors cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Clear Trace</span>
</button>
)}
</div>
{/* Suggested Quick Triggers */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 text-xs">
<span className="text-slate-400 font-semibold shrink-0">Try trigger:</span>
{skills
.filter((s) => s.prompt)
.map((s) => (
<button
key={s.id}
type="button"
onClick={() => runSimulation(s.prompt)}
className="px-3 py-1 rounded-full bg-brand-tint/70 border border-brand/20 text-brand-dark hover:bg-brand-tint transition-colors font-medium shrink-0 flex items-center gap-1.5 cursor-pointer"
>
<Sparkles className="w-3 h-3 text-brand" />
<span>"{s.prompt}"</span>
</button>
))}
</div>
{/* Interactive Trace Output Log */}
<div className="min-h-[260px] max-h-[420px] overflow-y-auto rounded-2xl bg-slate-900 text-slate-100 p-4 font-mono text-xs space-y-4">
{messages.length === 0 && !isRunning && (
<div className="text-slate-500 text-center py-12 flex flex-col items-center gap-2 font-sans">
<Terminal className="w-8 h-8 opacity-40" />
<div className="font-semibold text-sm">Playground session ready</div>
<p className="text-xs text-slate-400 max-w-sm">
Type a prompt or select a sample trigger above to test tool calling and deterministic intent execution.
</p>
</div>
)}
{messages.map((m) => (
<div key={m.id} className="space-y-2">
{m.role === 'user' ? (
<div className="flex items-start gap-2">
<span className="text-red-400 font-bold shrink-0">&gt; USER:</span>
<span className="text-white font-sans text-xs">{m.content}</span>
</div>
) : (
<div className="space-y-2 bg-slate-800/70 p-3.5 rounded-xl border border-slate-700/80">
<div className="flex items-center justify-between text-[11px] text-red-300 font-sans font-bold">
<span>AGENT INTENT RESOLUTION: {m.skill}</span>
<span className="text-emerald-400 flex items-center gap-1">
<CheckCircle2 className="w-3.5 h-3.5" />
Confidence: 99.4%
</span>
</div>
<div className="text-[11px] text-slate-400 font-sans">
<span className="text-slate-500">Reasoning: </span>
{m.thought}
</div>
{m.toolCall && (
<div className="bg-slate-950 p-2.5 rounded-lg border border-slate-800 text-[11px] space-y-1">
<div className="flex items-center gap-1.5 text-amber-400 font-bold">
<Wrench className="w-3 h-3" />
<span>Tool Execution: {m.toolCall.name}()</span>
</div>
<div className="text-slate-400 text-[10px]">
Args: {JSON.stringify(m.toolCall.args)}
</div>
</div>
)}
<div className="text-xs text-emerald-300 font-sans pt-1">
{m.output}
</div>
</div>
)}
</div>
))}
{isRunning && (
<div className="flex items-center gap-2 text-red-400 animate-pulse font-sans text-xs py-2">
<div className="w-3 h-3 border-2 border-red-400 border-t-transparent rounded-full animate-spin" />
<span>Agent evaluating prompt against capability registry...</span>
</div>
)}
</div>
{/* Prompt Input Box */}
<div className="relative flex items-center gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') runSimulation();
}}
placeholder="Ask or command the agent to test capability invocation..."
className="flex-1 bg-slate-50 border border-slate-200 rounded-2xl px-4 py-3 text-xs sm:text-sm text-slate-900 focus:outline-none focus:border-brand focus:bg-white shadow-2xs transition-all"
/>
<button
type="button"
onClick={() => runSimulation()}
disabled={!query.trim() || isRunning}
className="px-5 py-3 rounded-2xl bg-brand hover:bg-brand-dark disabled:opacity-50 text-white font-bold text-xs shadow-xs transition-all flex items-center gap-2 cursor-pointer disabled:cursor-not-allowed"
>
<span>Run</span>
<Send className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,289 @@
import React, { useState } from 'react';
import {
GraduationCap,
Sparkles,
ChevronDown,
ChevronRight,
LayoutGrid,
Zap,
Wrench,
Pencil,
Users,
Layers,
Check,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { AGENTS } from './agentRegistryData';
export default function AgentScopeNavigator({
activeAgentId,
onSelectAgent,
skills = [],
tools = [],
surfaces = [],
onSelectSkill,
onSelectTool,
}) {
const [surfacesOpen, setSurfacesOpen] = useState(false);
const [skillsOpen, setSkillsOpen] = useState(true);
const [agentSkillsOpen, setAgentSkillsOpen] = useState(true);
const [boardOpen, setBoardOpen] = useState(true);
const [toolsOpen, setToolsOpen] = useState(true);
const [agentMenuOpen, setAgentMenuOpen] = useState(false);
const currentAgent = AGENTS.find((a) => a.id === activeAgentId) || AGENTS[0];
const agentSkills = skills.filter((s) => s.scope !== 'board');
const boardSkills = skills.filter((s) => s.scope === 'board');
return (
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs p-4 flex flex-col space-y-4">
{/* 1. Agent Profile Header + Dropdown Switcher */}
<div className="relative">
<button
type="button"
onClick={() => setAgentMenuOpen((v) => !v)}
className="w-full flex items-center justify-between p-2 rounded-2xl hover:bg-slate-50 transition-colors text-left cursor-pointer group"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-2xl bg-brand-tint border border-brand/20 text-brand flex items-center justify-center shrink-0 shadow-3xs">
{currentAgent.iconType === 'grad' ? (
<GraduationCap className="w-5 h-5" />
) : (
<Sparkles className="w-5 h-5" />
)}
</div>
<div className="min-w-0">
<div className="text-xs sm:text-sm font-bold text-slate-900 truncate font-heading group-hover:text-brand transition-colors">
{currentAgent.name}
</div>
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mt-0.5">
{currentAgent.badge || 'AGENT'}
</div>
</div>
</div>
<ChevronDown className="w-4 h-4 text-slate-400 shrink-0 ml-1" />
</button>
{/* Agent Switcher Menu */}
{agentMenuOpen && (
<>
<div
className="fixed inset-0 z-40 bg-transparent"
onClick={() => setAgentMenuOpen(false)}
/>
<div className="absolute left-0 right-0 top-full mt-1.5 bg-white border border-slate-200 rounded-2xl shadow-xl p-1.5 z-50 animate-in fade-in zoom-in-95">
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider px-3 py-1.5">
Switch Agent Scope
</div>
{AGENTS.map((agent) => (
<button
key={agent.id}
type="button"
onClick={() => {
onSelectAgent(agent.id);
setAgentMenuOpen(false);
}}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-semibold text-left transition-colors cursor-pointer',
agent.id === activeAgentId
? 'bg-brand-tint text-brand'
: 'text-slate-700 hover:bg-slate-50'
)}
>
<span>{agent.name}</span>
{agent.id === activeAgentId && <Check className="w-3.5 h-3.5" />}
</button>
))}
</div>
</>
)}
</div>
<div className="h-px bg-slate-100" />
{/* 2. Hierarchical Collapsible Tree */}
<div className="space-y-3 text-xs">
{/* SURFACES TREE NODE */}
<div>
<button
type="button"
onClick={() => setSurfacesOpen((v) => !v)}
className="w-full flex items-center justify-between py-1 px-1.5 text-slate-700 hover:text-slate-900 rounded-lg font-semibold cursor-pointer group"
>
<div className="flex items-center gap-2">
{surfacesOpen ? (
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-400" />
)}
<LayoutGrid className="w-3.5 h-3.5 text-slate-500" />
<span className="font-bold">Surfaces</span>
</div>
<span className="text-[11px] font-bold text-slate-400">
{surfaces.length || 1}
</span>
</button>
{surfacesOpen && (
<div className="ml-6 pl-2.5 border-l border-slate-200/80 mt-1 space-y-1">
{surfaces.map((surf) => (
<div
key={surf.id}
className="py-1 px-2 rounded-lg text-slate-600 hover:text-slate-900 hover:bg-slate-50 text-[11px] font-medium truncate cursor-pointer"
title={surf.description}
>
{surf.name}
</div>
))}
</div>
)}
</div>
{/* SKILLS TREE NODE */}
<div>
<button
type="button"
onClick={() => setSkillsOpen((v) => !v)}
className="w-full flex items-center justify-between py-1 px-1.5 text-slate-700 hover:text-slate-900 rounded-lg font-semibold cursor-pointer group"
>
<div className="flex items-center gap-2">
{skillsOpen ? (
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-400" />
)}
<Zap className="w-3.5 h-3.5 text-slate-500" />
<span className="font-bold">Skills</span>
</div>
<span className="text-[11px] font-bold text-slate-400">
{skills.length}
</span>
</button>
{skillsOpen && (
<div className="ml-4 pl-2 border-l border-slate-200/80 mt-1 space-y-2">
{/* Agent Skills Sub-node */}
<div>
<button
type="button"
onClick={() => setAgentSkillsOpen((v) => !v)}
className="w-full flex items-center justify-between py-1 text-[11px] font-semibold text-slate-600 hover:text-slate-900 cursor-pointer"
>
<div className="flex items-center gap-1.5">
{agentSkillsOpen ? (
<ChevronDown className="w-3 h-3 text-slate-400" />
) : (
<ChevronRight className="w-3 h-3 text-slate-400" />
)}
<Zap className="w-3 h-3 text-slate-400" />
<span>Agent Skills</span>
</div>
<span className="text-[10px] text-slate-400 font-bold">
{agentSkills.length}
</span>
</button>
{agentSkillsOpen && (
<div className="ml-4 pl-2 border-l border-slate-200/80 mt-1 space-y-1">
{agentSkills.map((skill) => (
<button
key={skill.id}
type="button"
onClick={() => onSelectSkill(skill)}
className="w-full text-left py-1 px-2 rounded-lg text-slate-600 hover:text-brand hover:bg-brand-tint/60 text-[11px] font-medium flex items-center gap-1.5 truncate cursor-pointer"
>
{skill.category === 'AUTHORING' ? (
<Pencil className="w-3 h-3 shrink-0 text-brand" />
) : (
<Users className="w-3 h-3 shrink-0 text-indigo-500" />
)}
<span className="truncate">{skill.title}</span>
</button>
))}
</div>
)}
</div>
{/* Board Skills Sub-node */}
<div>
<button
type="button"
onClick={() => setBoardOpen((v) => !v)}
className="w-full flex items-center justify-between py-1 text-[11px] font-semibold text-slate-600 hover:text-slate-900 cursor-pointer"
>
<div className="flex items-center gap-1.5">
{boardOpen ? (
<ChevronDown className="w-3 h-3 text-slate-400" />
) : (
<ChevronRight className="w-3 h-3 text-slate-400" />
)}
<Layers className="w-3 h-3 text-slate-400" />
<span>Board Skills</span>
</div>
<span className="text-[10px] text-slate-400 font-bold">
{boardSkills.length}
</span>
</button>
{boardOpen && (
<div className="ml-4 pl-2 mt-1">
<p className="text-[10px] text-slate-400 leading-snug py-1">
None drawing on this agent's surfaces.
</p>
</div>
)}
</div>
</div>
)}
</div>
{/* TOOLS TREE NODE */}
<div>
<button
type="button"
onClick={() => setToolsOpen((v) => !v)}
className="w-full flex items-center justify-between py-1 px-1.5 text-slate-700 hover:text-slate-900 rounded-lg font-semibold cursor-pointer group"
>
<div className="flex items-center gap-2">
{toolsOpen ? (
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-400" />
)}
<Wrench className="w-3.5 h-3.5 text-slate-500" />
<span className="font-bold">Tools</span>
</div>
<span className="text-[11px] font-bold text-slate-400">
{tools.length}
</span>
</button>
{toolsOpen && (
<div className="ml-4 pl-2 border-l border-slate-200/80 mt-1 space-y-1.5">
{tools.slice(0, 4).map((tool) => (
<button
key={tool.name}
type="button"
onClick={() => onSelectTool(tool)}
className="w-full text-left py-1.5 px-2 rounded-xl text-slate-700 hover:text-brand hover:bg-brand-tint/60 transition-colors cursor-pointer group"
>
<div className="flex items-center gap-1.5">
<Wrench className="w-3 h-3 text-slate-400 group-hover:text-brand shrink-0" />
<span className="text-[11px] font-semibold truncate capitalize">
{tool.name.replace(/_/g, ' ')}
</span>
</div>
{tool.parentSkill && (
<div className="text-[9px] text-slate-400 font-medium pl-4 truncate">
{tool.parentSkill}
</div>
)}
</button>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,213 @@
import React, { useState, useEffect } from 'react';
import {
Sliders,
Zap,
Wrench,
Bot,
BarChart3,
ExternalLink,
Sparkles,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
AGENTS,
SURFACES,
loadStoredSkills,
saveStoredSkills,
loadStoredTools,
loadActiveAgentId,
saveActiveAgentId,
} from './agentRegistryData';
import AgentScopeNavigator from './AgentScopeNavigator';
import SkillCardsGrid from './SkillCardsGrid';
import SkillDetailsDrawer from './SkillDetailsDrawer';
import CreateSkillModal from './CreateSkillModal';
import ToolRegistryView from './ToolRegistryView';
import AgentPlayground from './AgentPlayground';
import { AgentConfigView, AgentInsightsView } from './AgentConfigAndInsights';
import { OpenToast } from '@/components/third-party/OpenToast';
const TOP_NAV_TABS = [
{ id: 'configure', label: 'Configure', icon: Sliders },
{ id: 'skills', label: 'Skills', icon: Zap, showBadge: true },
{ id: 'tools', label: 'Tools', icon: Wrench, showBadge: true },
{ id: 'test', label: 'Test', icon: Bot },
{ id: 'insights', label: 'Insights', icon: BarChart3 },
];
export default function AgentStudio() {
const [activeTab, setActiveTab] = useState('skills');
const [activeAgentId, setActiveAgentId] = useState(loadActiveAgentId());
const [skills, setSkills] = useState(loadStoredSkills());
const [tools, setTools] = useState(loadStoredTools());
const [selectedDrawerSkill, setSelectedDrawerSkill] = useState(null);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [testPrompt, setTestPrompt] = useState('');
// Persist agent choice
const handleSelectAgent = (id) => {
setActiveAgentId(id);
saveActiveAgentId(id);
const agent = AGENTS.find((a) => a.id === id);
OpenToast(`Switched active scope to ${agent?.name || id}`, 'info', 2000);
};
// Toggle Skill Status
const handleToggleStatus = (skillId) => {
setSkills((prev) => {
const next = prev.map((s) => {
if (s.id === skillId) {
const updated = { ...s, enabled: !s.enabled };
OpenToast(
`Skill "${s.title}" is now ${updated.enabled ? 'Enabled' : 'Disabled'}`,
updated.enabled ? 'success' : 'info',
2000
);
return updated;
}
return s;
});
saveStoredSkills(next);
return next;
});
// Sync drawer skill state if open
setSelectedDrawerSkill((prev) => {
if (prev && prev.id === skillId) {
return { ...prev, enabled: !prev.enabled };
}
return prev;
});
};
// Save new skill
const handleSaveNewSkill = (newSkill) => {
setSkills((prev) => {
const next = [newSkill, ...prev];
saveStoredSkills(next);
return next;
});
OpenToast(`Skill "${newSkill.title}" registered successfully`, 'success', 2500);
};
// Trigger test for skill
const handleTestSkill = (skill) => {
setTestPrompt(skill.prompt || `Test capability: ${skill.title}`);
setActiveTab('test');
OpenToast(`Loaded "${skill.title}" into test playground`, 'info', 1800);
};
const currentAgent = AGENTS.find((a) => a.id === activeAgentId) || AGENTS[0];
return (
<div className="space-y-5 pb-10">
{/* 1. Top Level Mode Tabs (Matching Reference Screenshot: Configure | Skills 2 | Tools | Test | Insights) */}
<div className="flex items-center gap-8 border-b border-slate-200/80 text-xs sm:text-sm font-semibold">
{TOP_NAV_TABS.map((tab) => {
const isActive = activeTab === tab.id;
const badgeCount =
tab.id === 'skills'
? skills.length
: tab.id === 'tools'
? tools.length
: null;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
'pb-3.5 relative transition-all cursor-pointer flex items-center gap-2 select-none',
isActive
? 'text-brand font-bold'
: 'text-slate-500 hover:text-slate-800'
)}
>
<span>{tab.label}</span>
{badgeCount !== null && (
<span
className={cn(
'px-1.5 py-0.5 rounded-full text-[11px] font-bold transition-colors',
isActive
? 'bg-brand-tint text-brand'
: 'bg-slate-100 text-slate-500'
)}
>
{badgeCount}
</span>
)}
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand rounded-full" />
)}
</button>
);
})}
</div>
{/* 2. Main Studio 2-Column Responsive Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
{/* Left Column: Scope & Agent Hierarchy Navigator */}
<div className="lg:col-span-4 xl:col-span-3">
<AgentScopeNavigator
activeAgentId={activeAgentId}
onSelectAgent={handleSelectAgent}
skills={skills}
tools={tools}
surfaces={SURFACES}
onSelectSkill={(skill) => setSelectedDrawerSkill(skill)}
onSelectTool={() => setActiveTab('tools')}
/>
</div>
{/* Right Column: Main Content Area */}
<div className="lg:col-span-8 xl:col-span-9">
{activeTab === 'skills' && (
<SkillCardsGrid
skills={skills}
activeAgent={currentAgent}
onTestSkill={handleTestSkill}
onOpenDetails={(skill) => setSelectedDrawerSkill(skill)}
onNewSkill={() => setCreateModalOpen(true)}
/>
)}
{activeTab === 'tools' && <ToolRegistryView tools={tools} />}
{activeTab === 'configure' && (
<AgentConfigView activeAgent={currentAgent} />
)}
{activeTab === 'test' && (
<AgentPlayground
activeAgent={currentAgent}
skills={skills}
initialPrompt={testPrompt}
/>
)}
{activeTab === 'insights' && <AgentInsightsView />}
</div>
</div>
{/* Slide-over Skill Details Drawer */}
<SkillDetailsDrawer
skill={selectedDrawerSkill}
tools={tools}
isOpen={Boolean(selectedDrawerSkill)}
onClose={() => setSelectedDrawerSkill(null)}
onToggleStatus={handleToggleStatus}
onTestSkill={handleTestSkill}
/>
{/* New Skill Creation Modal */}
<CreateSkillModal
isOpen={createModalOpen}
onClose={() => setCreateModalOpen(false)}
onSaveSkill={handleSaveNewSkill}
availableTools={tools}
activeAgentId={activeAgentId}
/>
</div>
);
}

View File

@@ -0,0 +1,227 @@
import React, { useState } from 'react';
import { X, Plus, Sparkles, Layers, Wrench, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
const CATEGORIES = ['Authoring', 'Workforce', 'Logistics', 'Dispatch', 'Custom'];
export default function CreateSkillModal({
isOpen,
onClose,
onSaveSkill,
availableTools = [],
activeAgentId,
}) {
const [title, setTitle] = useState('');
const [category, setCategory] = useState('Workforce');
const [description, setDescription] = useState('');
const [prompt, setPrompt] = useState('');
const [integration, setIntegration] = useState('MileTruth AI');
const [selectedTools, setSelectedTools] = useState([]);
const [error, setError] = useState('');
if (!isOpen) return null;
const toggleTool = (toolName) => {
setSelectedTools((prev) =>
prev.includes(toolName)
? prev.filter((t) => t !== toolName)
: [...prev, toolName]
);
};
const handleSubmit = (e) => {
e.preventDefault();
if (!title.trim()) {
setError('Please provide a skill title');
return;
}
if (!description.trim()) {
setError('Please provide a skill description');
return;
}
const newSkill = {
id: `skill-${Date.now()}`,
title: title.trim(),
category: category.toUpperCase(),
scope: 'agent',
integration: integration.trim() || 'Custom',
enabled: true,
statusBadge: 'Added',
description: description.trim(),
prompt: prompt.trim() || null,
tools: selectedTools,
agentId: activeAgentId || 'miletruth-agent',
createdAt: new Date().toISOString().split('T')[0],
};
onSaveSkill(newSkill);
onClose();
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="fixed inset-0 bg-slate-900/40 backdrop-blur-xs transition-opacity animate-in fade-in"
onClick={onClose}
/>
{/* Modal Dialog */}
<div className="relative bg-white rounded-3xl max-w-lg w-full shadow-2xl border border-slate-200 p-6 z-50 animate-in zoom-in-95 duration-150">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-slate-100">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-brand-tint text-brand flex items-center justify-center">
<Plus className="w-4 h-4 stroke-[2.5]" />
</div>
<div>
<h3 className="text-base font-bold text-slate-900 font-heading">
Register New Agent Skill
</h3>
<p className="text-[11px] text-slate-500">
Define capabilities and connect tools for this agent scope.
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4 pt-4 text-xs">
{error && (
<div className="p-2.5 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 font-semibold text-[11px]">
{error}
</div>
)}
{/* Skill Title */}
<div className="space-y-1">
<label className="font-bold text-slate-700">Skill Title *</label>
<input
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value);
setError('');
}}
placeholder="e.g. Dynamic Fleet Rebalance"
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white"
/>
</div>
{/* Category & Scope Integration */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="font-bold text-slate-700">Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="font-bold text-slate-700">Integration Tag</label>
<input
type="text"
value={integration}
onChange={(e) => setIntegration(e.target.value)}
placeholder="e.g. MileTruth AI, Doormile API"
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white"
/>
</div>
</div>
{/* Description */}
<div className="space-y-1">
<label className="font-bold text-slate-700">Description *</label>
<textarea
rows={2}
value={description}
onChange={(e) => {
setDescription(e.target.value);
setError('');
}}
placeholder="Detailed description of what this capability accomplishes for the agent..."
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white resize-none"
/>
</div>
{/* Sample Trigger Prompt */}
<div className="space-y-1">
<label className="font-bold text-slate-700 flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-brand" />
<span>Sample Trigger Prompt</span>
</label>
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder='e.g. "How is workforce training progressing?"'
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white"
/>
</div>
{/* Connect Tools Checklist */}
<div className="space-y-1.5">
<label className="font-bold text-slate-700 flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-slate-500" />
<span>Attach Tool Registry Endpoints</span>
</label>
<div className="max-h-32 overflow-y-auto border border-slate-200 rounded-xl p-2 bg-slate-50/50 space-y-1">
{availableTools.map((tool) => {
const isSelected = selectedTools.includes(tool.name);
return (
<button
key={tool.name}
type="button"
onClick={() => toggleTool(tool.name)}
className={cn(
'w-full flex items-center justify-between p-2 rounded-lg text-left text-[11px] transition-colors cursor-pointer',
isSelected
? 'bg-brand-tint text-brand font-semibold border border-brand/30'
: 'hover:bg-slate-100 text-slate-700'
)}
>
<span className="font-mono">{tool.name}</span>
{isSelected && <Check className="w-3.5 h-3.5 text-brand" />}
</button>
);
})}
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-100">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold text-xs cursor-pointer"
>
Cancel
</button>
<button
type="submit"
className="px-5 py-2 rounded-xl bg-brand hover:bg-brand-dark active:scale-95 text-white font-bold text-xs shadow-xs cursor-pointer"
>
Add Skill
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import React from 'react';
import {
Sparkles,
Play,
ArrowRight,
Pencil,
Users,
Package,
Route,
Check,
Layers,
CheckCircle2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
export default function SkillCard({ skill, onTest, onDetails }) {
const isEnabled = skill.enabled !== false;
// Icon and color mappings based on category
const getCategoryMeta = (cat) => {
switch ((cat || '').toUpperCase()) {
case 'AUTHORING':
return {
icon: Pencil,
textColor: 'text-brand',
bgColor: 'bg-brand-tint',
borderColor: 'border-brand/30',
};
case 'WORKFORCE':
return {
icon: Users,
textColor: 'text-indigo-700',
bgColor: 'bg-indigo-50/80',
borderColor: 'border-indigo-200/80',
};
case 'LOGISTICS':
return {
icon: Package,
textColor: 'text-emerald-700',
bgColor: 'bg-emerald-50/80',
borderColor: 'border-emerald-200/80',
};
case 'DISPATCH':
return {
icon: Route,
textColor: 'text-rose-700',
bgColor: 'bg-rose-50/80',
borderColor: 'border-rose-200/80',
};
default:
return {
icon: Layers,
textColor: 'text-slate-700',
bgColor: 'bg-slate-100',
borderColor: 'border-slate-200',
};
}
};
const meta = getCategoryMeta(skill.category);
const CategoryIcon = meta.icon;
return (
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs hover:shadow-sm hover:border-slate-300 transition-all p-5 flex flex-col justify-between relative group">
{/* Top Header: Category Tag (Left) & Status Badge (Right) */}
<div>
<div className="flex items-center justify-between gap-2 mb-3.5">
<div
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold tracking-wider border',
meta.bgColor,
meta.textColor,
meta.borderColor
)}
>
<CategoryIcon className="w-3.5 h-3.5" />
<span>{(skill.category || 'SKILL').toUpperCase()}</span>
</div>
<div
className={cn(
'inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold border',
isEnabled
? 'bg-brand-tint text-brand border-brand/30'
: 'bg-slate-100 text-slate-500 border-slate-200'
)}
>
{isEnabled ? (
<>
<Check className="w-3 h-3 stroke-[2.5]" />
<span>Added</span>
</>
) : (
<span>Disabled</span>
)}
</div>
</div>
{/* Skill Title */}
<h3 className="text-base sm:text-[17px] font-bold text-slate-900 font-heading leading-snug">
{skill.title}
</h3>
{/* Skill Description */}
<p className="text-xs sm:text-[13px] text-slate-600 mt-2 leading-relaxed line-clamp-3">
{skill.description}
</p>
{/* Prompt Trigger Pill (if present) */}
{skill.prompt && (
<div className="mt-3.5 p-2.5 rounded-2xl bg-brand-tint/40 border border-brand/20 text-xs text-brand font-medium flex items-center gap-2">
<Sparkles className="w-3.5 h-3.5 text-brand shrink-0" />
<span className="font-medium italic truncate">"{skill.prompt}"</span>
</div>
)}
{/* Scope / Integration Tag */}
{skill.integration && (
<div className="mt-3.5">
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl bg-slate-50 border border-slate-200/80 text-[11px] font-semibold text-slate-600">
<Layers className="w-3 h-3 text-slate-400" />
<span>{skill.integration}</span>
</div>
</div>
)}
</div>
{/* Footer Action Bar */}
<div className="flex items-center justify-between pt-4 mt-4 border-t border-slate-100 text-xs font-semibold">
<button
type="button"
onClick={() => onTest(skill)}
className="inline-flex items-center gap-1.5 text-brand hover:text-brand-dark transition-colors cursor-pointer group-hover:translate-x-0.5 transition-transform"
>
<Play className="w-3.5 h-3.5 fill-current" />
<span>Test in Playground</span>
</button>
<button
type="button"
onClick={() => onDetails(skill)}
className="inline-flex items-center gap-1 text-slate-500 hover:text-slate-900 transition-colors cursor-pointer"
>
<span>Details</span>
<ArrowRight className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,238 @@
import React, { useState, useMemo } from 'react';
import { Search, Plus, X, Layers, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import SkillCard from './SkillCard';
export default function SkillCardsGrid({
skills = [],
activeAgent,
onTestSkill,
onOpenDetails,
onNewSkill,
}) {
const [scopeTab, setScopeTab] = useState('agent'); // 'agent' or 'board'
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState('ALL');
// Filter by active agent and scope
const scopedSkills = useMemo(() => {
return skills.filter((s) => {
if (scopeTab === 'board') {
return s.scope === 'board';
}
return s.scope !== 'board';
});
}, [skills, scopeTab]);
// Categories present in the current scope
const categories = useMemo(() => {
const counts = { ALL: scopedSkills.length };
scopedSkills.forEach((s) => {
const cat = (s.category || 'GENERAL').toUpperCase();
counts[cat] = (counts[cat] || 0) + 1;
});
return counts;
}, [scopedSkills]);
// Filter by category and search query
const filteredSkills = useMemo(() => {
return scopedSkills.filter((s) => {
const matchesCat =
categoryFilter === 'ALL' ||
(s.category || '').toUpperCase() === categoryFilter;
const q = searchQuery.toLowerCase().trim();
const matchesQuery =
!q ||
(s.title || '').toLowerCase().includes(q) ||
(s.description || '').toLowerCase().includes(q) ||
(s.category || '').toLowerCase().includes(q) ||
(s.integration || '').toLowerCase().includes(q) ||
(s.prompt || '').toLowerCase().includes(q);
return matchesCat && matchesQuery;
});
}, [scopedSkills, categoryFilter, searchQuery]);
const enabledCount = filteredSkills.filter((s) => s.enabled !== false).length;
const agentCount = skills.filter((s) => s.scope !== 'board').length;
const boardCount = skills.filter((s) => s.scope === 'board').length;
return (
<div className="space-y-5">
{/* 1. Scope Sub-tabs (Agent Skills / Board Skills) */}
<div className="flex items-center gap-6 border-b border-slate-200/80 pb-px text-xs sm:text-sm font-semibold">
<button
type="button"
onClick={() => {
setScopeTab('agent');
setCategoryFilter('ALL');
}}
className={cn(
'pb-3 relative transition-colors cursor-pointer flex items-center gap-2',
scopeTab === 'agent'
? 'text-brand font-bold'
: 'text-slate-500 hover:text-slate-800'
)}
>
<span>Agent Skills</span>
<span
className={cn(
'px-1.5 py-0.5 rounded-full text-[11px] font-bold',
scopeTab === 'agent'
? 'bg-brand-tint text-brand'
: 'bg-slate-100 text-slate-500'
)}
>
{agentCount}
</span>
{scopeTab === 'agent' && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand rounded-full" />
)}
</button>
<button
type="button"
onClick={() => {
setScopeTab('board');
setCategoryFilter('ALL');
}}
className={cn(
'pb-3 relative transition-colors cursor-pointer flex items-center gap-2',
scopeTab === 'board'
? 'text-brand font-bold'
: 'text-slate-500 hover:text-slate-800'
)}
>
<span>Board Skills</span>
<span
className={cn(
'px-1.5 py-0.5 rounded-full text-[11px] font-bold',
scopeTab === 'board'
? 'bg-brand-tint text-brand'
: 'bg-slate-100 text-slate-500'
)}
>
{boardCount}
</span>
{scopeTab === 'board' && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand rounded-full" />
)}
</button>
</div>
{/* 2. Global Search Bar */}
<div className="relative">
<Search className="w-4 h-4 text-slate-400 absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search skills, capabilities or pages..."
className="w-full bg-white border border-slate-200/90 rounded-2xl pl-11 pr-10 py-3 text-xs sm:text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/20 shadow-2xs transition-all"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-3.5 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 rounded-full cursor-pointer"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* 3. Category Filter Pills */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 no-scrollbar">
{Object.entries(categories).map(([cat, count]) => {
const isSelected = categoryFilter === cat;
const displayLabel = cat === 'ALL' ? 'All' : cat.charAt(0) + cat.slice(1).toLowerCase();
return (
<button
key={cat}
type="button"
onClick={() => setCategoryFilter(cat)}
className={cn(
'px-4 py-1.5 rounded-full text-xs font-semibold transition-all whitespace-nowrap cursor-pointer shadow-2xs flex items-center gap-1.5',
isSelected
? 'bg-brand text-white shadow-xs'
: 'bg-white border border-slate-200/80 text-slate-600 hover:bg-slate-50 hover:text-slate-900'
)}
>
<span>{displayLabel}</span>
<span
className={cn(
'text-[11px] font-bold opacity-80',
isSelected ? 'text-white' : 'text-slate-400'
)}
>
{count}
</span>
</button>
);
})}
</div>
{/* 4. Capabilities Count & "+ New skill" Button */}
<div className="flex items-center justify-between pt-1 text-xs text-slate-500">
<div>
<span className="font-bold text-slate-800">
{filteredSkills.length} of {scopedSkills.length} capabilities
</span>{' '}
for this scope ·{' '}
<span className="font-bold text-brand">{enabledCount} enabled</span>
</div>
<button
type="button"
onClick={onNewSkill}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-white border border-slate-200/90 text-brand hover:bg-brand-tint hover:border-brand/30 font-bold text-xs shadow-2xs transition-all active:scale-95 cursor-pointer"
>
<Plus className="w-4 h-4 stroke-[2.5]" />
<span>New skill</span>
</button>
</div>
{/* 5. Responsive Skill Cards Grid */}
{scopeTab === 'board' && filteredSkills.length === 0 ? (
<div className="p-10 rounded-3xl border border-dashed border-slate-200 bg-slate-50/50 text-center space-y-2">
<Layers className="w-8 h-8 text-slate-400 mx-auto" />
<div className="text-sm font-bold text-slate-700">No Board Skills Connected</div>
<p className="text-xs text-slate-500 max-w-sm mx-auto">
None drawing on this agent's surfaces. Connect board skills to link live operational boards with this agent.
</p>
</div>
) : filteredSkills.length === 0 ? (
<div className="p-10 rounded-3xl border border-slate-200/80 bg-white text-center space-y-3 shadow-2xs">
<AlertCircle className="w-7 h-7 text-slate-400 mx-auto" />
<div className="text-sm font-bold text-slate-800">No matching capabilities found</div>
<p className="text-xs text-slate-500">
Try adjusting your search query or selecting a different category filter.
</p>
<button
type="button"
onClick={() => {
setSearchQuery('');
setCategoryFilter('ALL');
}}
className="px-4 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-xs font-semibold text-slate-700 transition-colors cursor-pointer"
>
Clear Filters
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{filteredSkills.map((skill) => (
<SkillCard
key={skill.id}
skill={skill}
onTest={onTestSkill}
onDetails={onOpenDetails}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,247 @@
import React, { useState } from 'react';
import {
X,
Sparkles,
Play,
Layers,
Wrench,
Check,
CheckCircle2,
Code,
ShieldCheck,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { cn } from '@/lib/utils';
export default function SkillDetailsDrawer({
skill,
tools = [],
isOpen,
onClose,
onToggleStatus,
onTestSkill,
}) {
const [schemaOpen, setSchemaOpen] = useState(false);
if (!isOpen || !skill) return null;
const isEnabled = skill.enabled !== false;
const linkedTools = tools.filter((t) => (skill.tools || []).includes(t.name));
return (
<div className="fixed inset-0 z-50 overflow-hidden">
{/* Backdrop */}
<div
className="fixed inset-0 bg-slate-900/30 backdrop-blur-xs transition-opacity animate-in fade-in"
onClick={onClose}
/>
{/* Slide-over Panel */}
<div className="fixed inset-y-0 right-0 max-w-full flex pl-10">
<div className="w-screen max-w-md bg-white shadow-2xl border-l border-slate-200 flex flex-col justify-between animate-in slide-in-from-right duration-200">
{/* Top Header */}
<div className="p-6 border-b border-slate-100 flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-bold tracking-wider bg-brand-tint text-brand border border-brand/30">
{(skill.category || 'SKILL').toUpperCase()}
</div>
<h2 className="text-lg font-bold text-slate-900 font-heading">
{skill.title}
</h2>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-xl text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Drawer Body Scroll Area */}
<div className="p-6 space-y-6 overflow-y-auto flex-1 text-xs">
{/* 1. Status & Enablement Toggle */}
<div className="p-4 rounded-2xl bg-slate-50 border border-slate-200/80 flex items-center justify-between">
<div>
<div className="font-bold text-slate-800 text-sm">Capability Status</div>
<div className="text-slate-500 text-[11px] mt-0.5">
{isEnabled ? 'Available to this agent on active surfaces' : 'Disabled for this agent'}
</div>
</div>
<button
type="button"
onClick={() => onToggleStatus(skill.id)}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
isEnabled ? 'bg-brand' : 'bg-slate-300'
)}
>
<span
className={cn(
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out',
isEnabled ? 'translate-x-5' : 'translate-x-0'
)}
/>
</button>
</div>
{/* 2. Overview & Purpose */}
<div className="space-y-2">
<h3 className="font-bold text-slate-800 uppercase tracking-wider text-[11px]">
Overview & Purpose
</h3>
<p className="text-slate-600 leading-relaxed text-xs">
{skill.description}
</p>
</div>
{/* 3. Sample Trigger Prompt */}
{skill.prompt && (
<div className="space-y-2">
<h3 className="font-bold text-slate-800 uppercase tracking-wider text-[11px]">
Sample Natural Language Trigger
</h3>
<div className="p-3 rounded-2xl bg-brand-tint/40 border border-brand/20 text-brand flex items-center justify-between gap-3">
<div className="flex items-center gap-2 truncate">
<Sparkles className="w-4 h-4 text-brand shrink-0" />
<span className="font-semibold italic text-xs truncate">"{skill.prompt}"</span>
</div>
<button
type="button"
onClick={() => {
onTestSkill(skill);
onClose();
}}
className="px-2.5 py-1 rounded-lg bg-brand hover:bg-brand-dark text-white font-bold text-[10px] shrink-0 transition-colors cursor-pointer"
>
Try Trigger
</button>
</div>
</div>
)}
{/* 4. Scope & Integration */}
<div className="space-y-2">
<h3 className="font-bold text-slate-800 uppercase tracking-wider text-[11px]">
Integration & Scope
</h3>
<div className="flex items-center gap-2">
<div className="px-3 py-1.5 rounded-xl bg-slate-100 border border-slate-200 font-semibold text-slate-700 flex items-center gap-2">
<Layers className="w-3.5 h-3.5 text-slate-500" />
<span>{skill.integration || 'System Core'}</span>
</div>
<div className="px-3 py-1.5 rounded-xl bg-slate-100 border border-slate-200 font-semibold text-slate-700 capitalize">
Scope: {skill.scope === 'board' ? 'Board' : 'Agent Core'}
</div>
</div>
</div>
{/* 5. Connected Tools from Registry */}
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<h3 className="font-bold text-slate-800 uppercase tracking-wider text-[11px]">
Connected Tool Registry Functions ({linkedTools.length})
</h3>
</div>
{linkedTools.length === 0 ? (
<div className="p-3 rounded-xl bg-slate-50 border border-slate-200 text-slate-500 text-xs">
No discrete tool endpoints attached to this skill definition.
</div>
) : (
<div className="space-y-2">
{linkedTools.map((tool) => (
<div
key={tool.name}
className="p-3 rounded-2xl bg-white border border-slate-200/90 shadow-2xs space-y-1.5"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 font-bold text-slate-900 font-mono text-[11px]">
<Wrench className="w-3 h-3 text-brand" />
<span>{tool.name}</span>
</div>
{tool.requiresConfirmation && (
<span className="px-2 py-0.5 rounded-full text-[9px] font-bold bg-amber-50 text-amber-800 border border-amber-200">
Requires Confirmation
</span>
)}
</div>
<p className="text-[11px] text-slate-600">{tool.description}</p>
{tool.parameters && tool.parameters.length > 0 && (
<div className="pt-1 text-[10px] text-slate-500">
<span className="font-semibold text-slate-700">Arguments: </span>
{tool.parameters.map((p) => p.name).join(', ')}
</div>
)}
</div>
))}
</div>
)}
</div>
{/* 6. JSONSchema Preview Accordion */}
<div className="border border-slate-200 rounded-2xl p-3 bg-slate-50/50">
<button
type="button"
onClick={() => setSchemaOpen((v) => !v)}
className="w-full flex items-center justify-between text-xs font-bold text-slate-700 cursor-pointer"
>
<div className="flex items-center gap-1.5">
<Code className="w-3.5 h-3.5 text-brand" />
<span>Inspect Agent Tool Schema</span>
</div>
{schemaOpen ? (
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-400" />
)}
</button>
{schemaOpen && (
<pre className="mt-2.5 p-2.5 bg-slate-900 text-slate-200 text-[10px] rounded-xl font-mono overflow-x-auto">
{JSON.stringify(
{
skill_id: skill.id,
name: skill.title,
scope: skill.scope,
tools: linkedTools.map((t) => ({
name: t.name,
parameters: t.parameters,
})),
},
null,
2
)}
</pre>
)}
</div>
</div>
{/* Drawer Footer Actions */}
<div className="p-4 border-t border-slate-100 bg-slate-50/60 flex items-center justify-between gap-3">
<button
type="button"
onClick={() => {
onTestSkill(skill);
onClose();
}}
className="flex-1 px-4 py-2 rounded-xl bg-brand hover:bg-brand-dark active:scale-95 text-white font-bold text-xs shadow-xs transition-all flex items-center justify-center gap-2 cursor-pointer"
>
<Play className="w-3.5 h-3.5 fill-current" />
<span>Test in Playground</span>
</button>
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-xl bg-white border border-slate-200 text-slate-700 font-semibold text-xs hover:bg-slate-100 transition-colors cursor-pointer"
>
Done
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,198 @@
import React, { useState } from 'react';
import {
Wrench,
Search,
Code,
ShieldAlert,
ShieldCheck,
ChevronDown,
ChevronRight,
Layers,
Plus,
} from 'lucide-react';
import { cn } from '@/lib/utils';
export default function ToolRegistryView({ tools = [] }) {
const [search, setSearch] = useState('');
const [expandedTool, setExpandedTool] = useState(null);
const filtered = tools.filter((t) => {
const q = search.toLowerCase().trim();
return (
!q ||
t.name.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
(t.parentSkill || '').toLowerCase().includes(q) ||
(t.category || '').toLowerCase().includes(q)
);
});
return (
<div className="space-y-5">
{/* Header & Stats Bar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-white p-5 rounded-3xl border border-slate-200/90 shadow-2xs">
<div>
<h3 className="text-base font-bold text-slate-900 font-heading flex items-center gap-2">
<Wrench className="w-4 h-4 text-brand" />
<span>Agent Tool Registry (Schema Engine)</span>
</h3>
<p className="text-xs text-slate-500 mt-1">
Central registry providing JSONSchema parameter validation and human-in-the-loop confirmation gates.
</p>
</div>
<div className="flex items-center gap-4 text-xs font-semibold">
<div className="px-3 py-1.5 rounded-xl bg-slate-50 border border-slate-200 text-slate-700">
<span className="text-slate-400 font-normal">Total Tools: </span>
<span className="font-bold text-slate-900">{tools.length}</span>
</div>
<div className="px-3 py-1.5 rounded-xl bg-amber-50 border border-amber-200 text-amber-800">
<span className="text-amber-600/80 font-normal">Gated Actions: </span>
<span className="font-bold">
{tools.filter((t) => t.requiresConfirmation).length}
</span>
</div>
</div>
</div>
{/* Search Input */}
<div className="relative">
<Search className="w-4 h-4 text-slate-400 absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter tools by function name, category or description..."
className="w-full bg-white border border-slate-200/90 rounded-2xl pl-11 pr-4 py-2.5 text-xs sm:text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/15 shadow-2xs transition-all"
/>
</div>
{/* Tools Table Container */}
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-slate-50/80 border-b border-slate-200/80 text-[11px] font-bold text-slate-600 uppercase tracking-wider">
<tr>
<th className="py-3 px-4">Tool Function</th>
<th className="py-3 px-4">Category</th>
<th className="py-3 px-4">Parent Skill</th>
<th className="py-3 px-4">Safety Gate</th>
<th className="py-3 px-4 text-right">Arguments</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((tool) => {
const isExpanded = expandedTool === tool.name;
return (
<React.Fragment key={tool.name}>
<tr
onClick={() =>
setExpandedTool(isExpanded ? null : tool.name)
}
className={cn(
'hover:bg-slate-50/80 transition-colors cursor-pointer select-none',
isExpanded && 'bg-brand-tint/30'
)}
>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="w-3.5 h-3.5 text-brand" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-400" />
)}
<span className="font-mono font-bold text-slate-900 text-xs">
{tool.name}
</span>
</div>
<div className="text-[11px] text-slate-500 mt-0.5 pl-5 line-clamp-1">
{tool.description}
</div>
</td>
<td className="py-3 px-4">
<span className="inline-flex px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-slate-100 text-slate-700">
{tool.category || 'General'}
</span>
</td>
<td className="py-3 px-4 font-medium text-slate-700">
{tool.parentSkill || '—'}
</td>
<td className="py-3 px-4">
{tool.requiresConfirmation ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-50 text-amber-800 border border-amber-200">
<ShieldAlert className="w-3 h-3" />
Confirmation Required
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-200">
<ShieldCheck className="w-3 h-3" />
Autonomous
</span>
)}
</td>
<td className="py-3 px-4 text-right">
<span className="px-2.5 py-1 rounded-xl bg-slate-100 text-slate-700 font-bold text-[11px]">
{tool.parameters?.length || 0} params
</span>
</td>
</tr>
{/* Expandable JSONSchema Parameters View */}
{isExpanded && (
<tr className="bg-slate-50/90">
<td colSpan={5} className="p-4 pl-9 space-y-3">
<div className="text-xs font-bold text-slate-800 flex items-center gap-2">
<Code className="w-3.5 h-3.5 text-brand" />
<span>Tool Parameters Schema (OpenAI / Claude JSONSchema Specification)</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{tool.parameters?.map((param) => (
<div
key={param.name}
className="p-3 bg-white rounded-xl border border-slate-200 text-xs space-y-1"
>
<div className="flex items-center justify-between">
<span className="font-mono font-bold text-brand">
{param.name}
</span>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded-md bg-slate-100 text-slate-600">
{param.type}
</span>
{param.required && (
<span className="text-[9px] font-bold text-rose-600 bg-rose-50 px-1 rounded">
required
</span>
)}
</div>
</div>
<p className="text-[11px] text-slate-600">
{param.description}
</p>
{param.default !== undefined && (
<div className="text-[10px] text-slate-400 font-mono">
default: {String(param.default)}
</div>
)}
</div>
))}
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,241 @@
// ==============================|| Agent Studio Data & Registry Layer ||============================== //
// Modeled directly after AI_engine/core/tool_registry.py and the reference screenshot.
export const AGENTS = [
{
id: 'miletruth-agent',
name: 'MileTruth AI Agent',
badge: 'PRIMARY AGENT',
iconType: 'sparkles',
description: 'Autonomous logistics intelligence, workforce analytics, order scheduling, and dynamic fleet dispatch.',
activeSurfacesCount: 3,
},
{
id: 'miletruth-dispatch-agent',
name: 'MileTruth AI Dispatcher',
badge: 'AGENT',
iconType: 'bot',
description: 'Real-time courier routing, SLA breach mitigation, and autonomous fleet allocation.',
activeSurfacesCount: 2,
},
];
export const SURFACES = [
{
id: 'surf-home',
name: 'Home AI Assistant',
path: '/doormile/home',
description: 'Conversational natural language interface for fleet queries and order creation.',
status: 'active',
},
{
id: 'surf-dispatch',
name: 'Live Dispatch Board',
path: '/doormile/dispatch',
description: 'Real-time kanban grid and map board with autonomous allocation controls.',
status: 'active',
},
{
id: 'surf-control-x',
name: 'Control X Fast Ops',
path: '/doormile/control-x',
description: 'High-speed keyboard-driven workspace for rapid order actions and exceptions.',
status: 'active',
},
];
export const DEFAULT_TOOLS = [
{
name: 'open_add_skill_training',
parentSkill: 'MileTruth Skill Management',
description: 'Launch modal to configure workforce training modules and curriculum tracks.',
category: 'Authoring',
requiresConfirmation: false,
parameters: [
{ name: 'skill_id', type: 'string', description: 'Unique identifier of the target skill', required: true },
{ name: 'curriculum_track', type: 'string', description: 'Assigned curriculum difficulty tier', required: false, default: 'standard' },
],
},
{
name: 'open_add_training',
parentSkill: 'MileTruth Skill Management',
description: 'Add specific interactive training exercises, quizzes, or step-by-step checklists.',
category: 'Authoring',
requiresConfirmation: false,
parameters: [
{ name: 'training_title', type: 'string', description: 'Title of the training unit', required: true },
{ name: 'estimated_minutes', type: 'number', description: 'Duration in minutes', required: false, default: 15 },
],
},
{
name: 'open_miletruth_ai',
parentSkill: 'MileTruth Skill Management',
description: 'Direct deep-link launch into the MileTruth AI authoring and verification studio.',
category: 'Authoring',
requiresConfirmation: false,
parameters: [
{ name: 'workspace_view', type: 'string', description: 'Initial view mode in MileTruth AI', required: false, default: 'editor' },
],
},
{
name: 'query_learning_analytics',
parentSkill: 'Learning Analysis',
description: 'Query completion rates, assessment scores, and training progress across teams.',
category: 'Workforce',
requiresConfirmation: false,
parameters: [
{ name: 'team_id', type: 'string', description: 'Specific workforce team or all', required: false, default: 'all' },
{ name: 'timeframe', type: 'string', description: '7d, 30d, or 90d window', required: false, default: '30d' },
],
},
{
name: 'create_single_order',
parentSkill: 'Order Intake & Auto-Schedule',
description: 'Create an individual delivery booking in Doormile with geocoded coordinates.',
category: 'Logistics',
requiresConfirmation: true,
parameters: [
{ name: 'pickup_address', type: 'string', description: 'Full verified pickup address', required: true },
{ name: 'drop_address', type: 'string', description: 'Full verified destination address', required: true },
{ name: 'client_id', type: 'string', description: 'Billing client identifier', required: true },
{ name: 'weight_kg', type: 'number', description: 'Estimated package weight', required: false, default: 1.0 },
],
},
{
name: 'rebalance_riders',
parentSkill: 'Dispatch Rebalance & Allocation',
description: 'Autonomous algorithmic reallocation of idle or nearby riders to high-demand zones.',
category: 'Dispatch',
requiresConfirmation: true,
parameters: [
{ name: 'source_zone', type: 'string', description: 'Zone with surplus rider capacity', required: true },
{ name: 'target_zone', type: 'string', description: 'Zone experiencing demand spike', required: true },
{ name: 'rider_count', type: 'integer', description: 'Number of riders to request reassignment', required: true },
],
},
];
export const DEFAULT_SKILLS = [
{
id: 'miletruth-skill-mgmt',
title: 'MileTruth Skill Management',
category: 'AUTHORING',
categoryTheme: 'red',
scope: 'agent',
integration: 'MileTruth AI',
enabled: true,
statusBadge: 'Added',
description: 'Create workforce skills, training and verification in MileTruth AI.',
prompt: null,
tools: ['open_add_skill_training', 'open_add_training', 'open_miletruth_ai'],
agentId: 'miletruth-agent',
createdAt: '2026-03-10',
},
{
id: 'learning-analysis',
title: 'Learning Analysis',
category: 'WORKFORCE',
categoryTheme: 'indigo',
scope: 'agent',
integration: 'MileTruth AI',
enabled: true,
statusBadge: 'Added',
description: 'Report what the training library holds and how far the workforce has progressed through it.',
prompt: 'How is training progressing?',
tools: ['query_learning_analytics'],
agentId: 'miletruth-agent',
createdAt: '2026-03-12',
},
{
id: 'order-intake-schedule',
title: 'Order Intake & Auto-Schedule',
category: 'LOGISTICS',
categoryTheme: 'emerald',
scope: 'agent',
integration: 'Doormile API',
enabled: true,
statusBadge: 'Added',
description: 'Parse multi-item orders from batch text or CSV and schedule with live dispatch.',
prompt: "Repeat yesterday's orders for client and schedule run",
tools: ['create_single_order'],
agentId: 'miletruth-agent',
createdAt: '2026-03-15',
},
{
id: 'dispatch-rebalance-alloc',
title: 'Dispatch Rebalance & Allocation',
category: 'DISPATCH',
categoryTheme: 'rose',
scope: 'agent',
integration: 'Doormile Fleet',
enabled: true,
statusBadge: 'Added',
description: 'Analyze live SLA queues and automatically reassign unassigned orders to active nearby couriers.',
prompt: 'Rebalance available riders into Zone 1 to prevent SLA delays',
tools: ['rebalance_riders'],
agentId: 'miletruth-agent',
createdAt: '2026-03-18',
},
];
const STORAGE_KEY_SKILLS = 'doormile_agent_skills_v2';
const STORAGE_KEY_TOOLS = 'doormile_agent_tools_v2';
const STORAGE_KEY_AGENT = 'doormile_active_agent_v2';
export function loadStoredSkills() {
try {
const raw = localStorage.getItem(STORAGE_KEY_SKILLS);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
}
} catch (e) {
console.warn('Failed to parse stored skills, using defaults:', e);
}
return DEFAULT_SKILLS;
}
export function saveStoredSkills(skills) {
try {
localStorage.setItem(STORAGE_KEY_SKILLS, JSON.stringify(skills));
} catch (e) {
console.error('Failed to save skills to localStorage:', e);
}
}
export function loadStoredTools() {
try {
const raw = localStorage.getItem(STORAGE_KEY_TOOLS);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
}
} catch (e) {
console.warn('Failed to parse stored tools, using defaults:', e);
}
return DEFAULT_TOOLS;
}
export function saveStoredTools(tools) {
try {
localStorage.setItem(STORAGE_KEY_TOOLS, JSON.stringify(tools));
} catch (e) {
console.error('Failed to save tools to localStorage:', e);
}
}
export function loadActiveAgentId() {
try {
return localStorage.getItem(STORAGE_KEY_AGENT) || 'miletruth-agent';
} catch {
return 'miletruth-agent';
}
}
export function saveActiveAgentId(id) {
try {
localStorage.setItem(STORAGE_KEY_AGENT, id);
} catch (e) {
console.warn('Failed to store active agent:', e);
}
}

View File

@@ -0,0 +1,124 @@
import React, { useState } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import dayjs from 'dayjs';
jest.mock('lucide-react', () => ({
Calendar: () => <span data-testid="icon-calendar" />,
ChevronLeft: () => <span data-testid="icon-left" />,
ChevronRight: () => <span data-testid="icon-right" />,
ChevronDown: () => <span data-testid="icon-down" />,
}));
import { DatePicker } from '@/components/ds/DatePicker';
import { toDateRange } from '@/lib/dateRange';
/**
* What the picker hands its consumer, for each control.
*
* "This Month is not even responding" was the report. It emits `{from, to}`,
* not a date string — and the Deliveries page forced it through
* `dayjs().format()`, which is Invalid Date for an object. Worse, the handler
* called a setter that did not exist, and the ReferenceError propagated out of
* `commit` BEFORE `setOpen(false)`, so the popover never even closed. Clicking
* the preset genuinely did nothing visible.
*/
// Radix Popover needs these in jsdom.
beforeAll(() => {
if (!window.ResizeObserver) {
window.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
}
if (!Element.prototype.hasPointerCapture) {
Element.prototype.hasPointerCapture = () => false;
Element.prototype.setPointerCapture = () => {};
Element.prototype.releasePointerCapture = () => {};
}
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {};
});
const today = dayjs().startOf('day');
const todayStr = today.format('YYYY-MM-DD');
// The collapsed picker renders three buttons — prev-day, the trigger,
// next-day — so the trigger is selected by its popup role, not by position.
const openCalendar = () => fireEvent.click(document.querySelector('[aria-haspopup="dialog"]'));
const openPicker = (onChange, value = todayStr) => {
render(<DatePicker value={value} onChange={onChange} />);
openCalendar();
};
describe('DatePicker — what each control emits', () => {
it('This Month emits a RANGE object, not a date string', () => {
const onChange = jest.fn();
openPicker(onChange);
fireEvent.click(screen.getByText('This Month'));
expect(onChange).toHaveBeenCalledTimes(1);
const emitted = onChange.mock.calls[0][0];
expect(typeof emitted).toBe('object');
expect(emitted).toEqual({ from: today.startOf('month').format('YYYY-MM-DD'), to: todayStr });
});
it('Last 7 Days emits a range covering today and the six before it', () => {
const onChange = jest.fn();
openPicker(onChange);
fireEvent.click(screen.getByText('Last 7 Days'));
expect(onChange).toHaveBeenCalledWith({
from: today.subtract(6, 'day').format('YYYY-MM-DD'),
to: todayStr
});
});
it('All Dates emits the string "all", which is not a date', () => {
const onChange = jest.fn();
openPicker(onChange);
fireEvent.click(screen.getByText('All Dates'));
expect(onChange).toHaveBeenCalledWith('all');
});
it('Yesterday emits a plain YYYY-MM-DD string', () => {
const onChange = jest.fn();
openPicker(onChange);
fireEvent.click(screen.getByText('Yesterday'));
expect(onChange).toHaveBeenCalledWith(today.subtract(1, 'day').format('YYYY-MM-DD'));
});
it('every emitted shape survives toDateRange with real bounds', () => {
// The contract the Deliveries page depends on: whatever the picker emits,
// the page can turn it into usable bounds without inspecting its type.
const emitted = [];
const onChange = (v) => emitted.push(v);
['This Month', 'Last 7 Days', 'Yesterday'].forEach((label) => {
const { unmount } = render(<DatePicker value={todayStr} onChange={onChange} />);
openCalendar();
fireEvent.click(screen.getByText(label));
unmount();
});
expect(emitted).toHaveLength(3);
emitted.forEach((v) => {
const { from, to } = toDateRange(v);
expect(from).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(to).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(from <= to).toBe(true);
});
});
});
describe('DatePicker — the popover after a choice', () => {
it('closes once a preset is chosen', () => {
const onChange = jest.fn();
openPicker(onChange);
expect(screen.getByText('This Month')).toBeInTheDocument();
fireEvent.click(screen.getByText('This Month'));
expect(screen.queryByText('This Month')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,204 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import {
DEFAULT_SKILLS,
DEFAULT_TOOLS,
AGENTS,
loadStoredSkills,
saveStoredSkills,
loadStoredTools,
loadActiveAgentId,
saveActiveAgentId,
} from '@/pages/doormile/settings/agentStudio/agentRegistryData';
// Stub lucide-react ESM exports for Jest
jest.mock('lucide-react', () => {
return new Proxy(
{},
{
get: (target, prop) => {
if (prop === '__esModule') return true;
return (props) => <span data-testid={`icon-${String(prop)}`} {...props} />;
},
}
);
});
// Stub framer-motion layout animations for jsdom
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, ...rest }) => <div {...rest}>{children}</div>,
span: ({ children, ...rest }) => <span {...rest}>{children}</span>,
},
AnimatePresence: ({ children }) => <>{children}</>,
}));
import SkillCard from '@/pages/doormile/settings/agentStudio/SkillCard';
import SkillCardsGrid from '@/pages/doormile/settings/agentStudio/SkillCardsGrid';
import ToolRegistryView from '@/pages/doormile/settings/agentStudio/ToolRegistryView';
describe('Agent Studio — Data Layer & Persistence', () => {
beforeEach(() => {
localStorage.clear();
});
it('pre-seeds default agents and surfaces correctly', () => {
expect(AGENTS.length).toBeGreaterThanOrEqual(2);
expect(AGENTS.some((a) => a.name === 'MileTruth AI Agent')).toBe(true);
expect(AGENTS.some((a) => a.name === 'MileTruth AI Dispatcher')).toBe(true);
});
it('pre-seeds default skills matching reference image', () => {
expect(DEFAULT_SKILLS.some((s) => s.title === 'MileTruth Skill Management')).toBe(true);
expect(DEFAULT_SKILLS.some((s) => s.title === 'Learning Analysis')).toBe(true);
const learningSkill = DEFAULT_SKILLS.find((s) => s.title === 'Learning Analysis');
expect(learningSkill.prompt).toBe('How is training progressing?');
expect(learningSkill.category).toBe('WORKFORCE');
});
it('persists modified skills to localStorage and restores them', () => {
const customSkill = {
id: 'custom-skill-1',
title: 'Custom Automation Skill',
category: 'LOGISTICS',
scope: 'agent',
enabled: true,
description: 'Test custom capability',
tools: ['create_single_order'],
};
saveStoredSkills([customSkill]);
const restored = loadStoredSkills();
expect(restored.length).toBe(1);
expect(restored[0].title).toBe('Custom Automation Skill');
});
it('saves and loads active agent id', () => {
saveActiveAgentId('miletruth-agent');
expect(loadActiveAgentId()).toBe('miletruth-agent');
});
});
describe('Agent Studio — SkillCard Component', () => {
const sampleSkill = {
id: 'test-skill-1',
title: 'MileTruth Skill Management',
category: 'AUTHORING',
scope: 'agent',
integration: 'MileTruth AI',
enabled: true,
description: 'Create workforce skills, training and verification in MileTruth AI.',
prompt: 'How is training progressing?',
tools: ['open_add_training'],
};
it('renders title, description, category, and prompt trigger bubble', () => {
render(<SkillCard skill={sampleSkill} onTest={() => {}} onDetails={() => {}} />);
expect(screen.getByText('MileTruth Skill Management')).toBeInTheDocument();
expect(
screen.getByText('Create workforce skills, training and verification in MileTruth AI.')
).toBeInTheDocument();
expect(screen.getByText('AUTHORING')).toBeInTheDocument();
expect(screen.getByText('"How is training progressing?"')).toBeInTheDocument();
expect(screen.getByText('MileTruth AI')).toBeInTheDocument();
});
it('fires onTest when Test in Playground is clicked', () => {
const handleTest = jest.fn();
render(<SkillCard skill={sampleSkill} onTest={handleTest} onDetails={() => {}} />);
const testBtn = screen.getByText('Test in Playground');
fireEvent.click(testBtn);
expect(handleTest).toHaveBeenCalledWith(sampleSkill);
});
it('fires onDetails when Details button is clicked', () => {
const handleDetails = jest.fn();
render(<SkillCard skill={sampleSkill} onTest={() => {}} onDetails={handleDetails} />);
const detailsBtn = screen.getByText('Details');
fireEvent.click(detailsBtn);
expect(handleDetails).toHaveBeenCalledWith(sampleSkill);
});
});
describe('Agent Studio — SkillCardsGrid Component', () => {
it('renders skill cards and filters by search query', () => {
render(
<SkillCardsGrid
skills={DEFAULT_SKILLS}
activeAgent={AGENTS[0]}
onTestSkill={() => {}}
onOpenDetails={() => {}}
onNewSkill={() => {}}
/>
);
expect(screen.getByText('MileTruth Skill Management')).toBeInTheDocument();
expect(screen.getByText('Learning Analysis')).toBeInTheDocument();
const searchInput = screen.getByPlaceholderText('Search skills, capabilities or pages...');
fireEvent.change(searchInput, { target: { value: 'Learning' } });
expect(screen.queryByText('MileTruth Skill Management')).not.toBeInTheDocument();
expect(screen.getByText('Learning Analysis')).toBeInTheDocument();
});
it('filters by category pill selection', () => {
render(
<SkillCardsGrid
skills={DEFAULT_SKILLS}
activeAgent={AGENTS[0]}
onTestSkill={() => {}}
onOpenDetails={() => {}}
onNewSkill={() => {}}
/>
);
// Click 'Authoring' category pill
const authoringPill = screen.getByText('Authoring');
fireEvent.click(authoringPill);
expect(screen.getByText('MileTruth Skill Management')).toBeInTheDocument();
expect(screen.queryByText('Learning Analysis')).not.toBeInTheDocument();
});
it('triggers onNewSkill when clicking New skill button', () => {
const handleNewSkill = jest.fn();
render(
<SkillCardsGrid
skills={DEFAULT_SKILLS}
activeAgent={AGENTS[0]}
onTestSkill={() => {}}
onOpenDetails={() => {}}
onNewSkill={handleNewSkill}
/>
);
const newSkillBtn = screen.getByText('New skill');
fireEvent.click(newSkillBtn);
expect(handleNewSkill).toHaveBeenCalled();
});
});
describe('Agent Studio — ToolRegistryView Component', () => {
it('renders tool registry table and displays argument schemas', () => {
render(<ToolRegistryView tools={DEFAULT_TOOLS} />);
expect(screen.getByText('open_add_skill_training')).toBeInTheDocument();
expect(screen.getByText('create_single_order')).toBeInTheDocument();
expect(screen.getByText('rebalance_riders')).toBeInTheDocument();
});
it('filters tool list by search input', () => {
render(<ToolRegistryView tools={DEFAULT_TOOLS} />);
const searchInput = screen.getByPlaceholderText(
'Filter tools by function name, category or description...'
);
fireEvent.change(searchInput, { target: { value: 'rebalance' } });
expect(screen.getByText('rebalance_riders')).toBeInTheDocument();
expect(screen.queryByText('open_add_skill_training')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,54 @@
import { toDateRange } from '@/lib/dateRange';
/**
* The three shapes the DatePicker emits, and what a page must do with them.
*
* The bug: the Deliveries page ran every emitted value through
* `dayjs(d).format('YYYY-MM-DD')`. For a range preset that is
* `dayjs({ from, to })` and for All Dates it is `dayjs('all')` — both Invalid
* Date — so the filter compared every row against the string "Invalid Date".
*/
describe('toDateRange', () => {
it('turns a single day into a one-day range', () => {
expect(toDateRange('2026-09-22')).toEqual({ from: '2026-09-22', to: '2026-09-22' });
});
it('passes a range preset straight through — This Month, Last 7 Days', () => {
expect(toDateRange({ from: '2026-09-01', to: '2026-09-22' })).toEqual({
from: '2026-09-01',
to: '2026-09-22'
});
});
it('turns All Dates into no bounds, not into a date', () => {
expect(toDateRange('all')).toEqual({ from: '', to: '' });
});
it('treats a cleared picker as no bounds', () => {
expect(toDateRange(null)).toEqual({ from: '', to: '' });
expect(toDateRange(undefined)).toEqual({ from: '', to: '' });
expect(toDateRange('')).toEqual({ from: '', to: '' });
});
it('keeps a half-open range open on the missing side', () => {
// An empty bound means unbounded there, which is what the API layer's
// inRange already expects — not "filter everything out".
expect(toDateRange({ from: '2026-09-01' })).toEqual({ from: '2026-09-01', to: '' });
expect(toDateRange({ to: '2026-09-22' })).toEqual({ from: '', to: '2026-09-22' });
expect(toDateRange({})).toEqual({ from: '', to: '' });
});
it('never produces "Invalid Date" — the actual defect, in one assertion', () => {
const values = ['all', null, undefined, {}, { from: '2026-09-01', to: '2026-09-22' }, '2026-09-22'];
values.forEach((v) => {
const { from, to } = toDateRange(v);
expect(from).not.toMatch(/Invalid/);
expect(to).not.toMatch(/Invalid/);
});
});
it('is empty, not a crash, for a shape nobody should pass', () => {
expect(toDateRange(20260922)).toEqual({ from: '', to: '' });
expect(toDateRange(true)).toEqual({ from: '', to: '' });
});
});

View File

@@ -0,0 +1,211 @@
import {
assignmentSummary,
editNeedsReconcile,
extractRiders,
extractUnassigned,
flattenRiders,
moveOrderInPreviewData,
withUnassignedZones
} from '@/lib/dispatchPreview';
/**
* One real failed run, start to finish.
*
* LIVE_RUN is the verbatim reply from
* `routes.workolik.com/api/v1/optimization/riderassign?hypertuning_params=balanced`
* on 2026-09-22 for booking DM-107871 — the run in the screenshots. It is
* kept whole rather than trimmed: the defect these guard against was an
* assumption about the response shape, and a fixture written from the same
* assumption proves nothing.
*
* What it records: the solver processed the order correctly (zone resolved,
* profit computed) and had no rider to give it to — `active_riders_pool: 0`,
* before any filtering, with nothing excluded.
*/
const LIVE_RUN = {
code: 200,
zone_summary: [
{
zone_name: 'North West',
total_orders: 1,
assigned_orders: 0,
unassigned_orders_count: 1,
active_riders_count: 0,
total_delivery_kms: 0.0,
total_profit: 56.0
}
],
zones: [
{
zone_name: 'North West',
total_orders: 1,
active_riders_count: 0,
assigned_orders: 0,
unassigned_orders_count: 1,
total_delivery_kms: 0.0,
total_profit: 56.0,
riders: [],
unassigned_orders: [
{
bookingid: 1737,
bookingno: 'DM-107871',
tenantid: 1,
pickupaddress: '124 Gandhipuram Main Rd',
pickuplatitude: 11.0168,
pickuplongitude: 76.9558,
deliveryaddress: 'NO . 26, JMV GARDEN, Subramaniapalayam, Coimbatore, Tamil Nadu 641029',
deliverylatitude: 11.0590674,
deliverylongitude: 76.9298476,
status: 'Pending_Pickup',
assignedmileruserid: null,
consignmentid: null,
deliveryid: 0,
deliverydate: '2026-09-23 17:56:04',
assigntime: '2026-09-22 13:54:39',
orderstatus: 'pending',
orderamount: 56,
droplat: '11.0590674',
droplon: '76.9298476',
pickuplat: 11.0168,
pickuplon: 76.9558,
deliverycharges: 56,
pickuplocation: '124 Gandhipuram Main Rd',
deliverylocation: 'NO . 26, JMV GARDEN, Subramaniapalayam, Coimbatore, Tamil Nadu 641029',
hubid: 2,
deliverylat: '11.0590674',
deliverylong: '76.9298476',
unassigned_reason: 'No riders found (check partner online status).',
zone_name: 'North West',
rider_charge: 0.0,
profit: 56.0,
profit_class: 'HighMargin'
}
]
}
],
details: [],
message: 'Success',
status: true,
meta: {
total_orders: 1,
utilized_riders: 0,
active_riders_pool: 0,
assigned_orders: 0,
unassigned_orders: 1,
total_profit: 56.0,
unassigned_details: [{ orderid: null, reason: 'No riders found (check partner online status).' }],
absent_riders_excluded: [],
absent_riders_unknown: [],
solver_mode: '2phase_heuristic'
}
};
// The order id every surface keys this booking by. The refusal list carries
// no `orderid` of its own — `bookingno` is what it has.
const ORDER_ID = 'DM-107871';
describe('the run as the page reads it', () => {
it('reports the refusal instead of the 200', () => {
expect(assignmentSummary(LIVE_RUN)).toMatchObject({
assigned: 0,
unassigned: 1,
total: 1,
riders: 0,
failed: true
});
});
it('has nothing to commit before a miler is picked', () => {
// `details: []` — so the Assign Orders fallback is empty too, and the
// page must not offer to dispatch anything.
expect(flattenRiders(extractRiders(LIVE_RUN))).toEqual([]);
expect(LIVE_RUN.details).toEqual([]);
});
it('puts the order on the board with its coordinates, so it can be pinned', () => {
const order = withUnassignedZones(LIVE_RUN).zones[0].riders[0].orders[0];
expect(order.orderid).toBe(ORDER_ID);
expect(Number(order.droplat)).toBeCloseTo(11.0590674);
expect(Number(order.droplon)).toBeCloseTo(76.9298476);
});
});
describe('recovering the run by hand', () => {
// The operator opens the zone, presses "change rider" on DM-107871 and
// picks a real Doormile miler. Everything below has to follow from that.
const recovered = () =>
moveOrderInPreviewData(LIVE_RUN, { orderId: ORDER_ID, newRiderId: 12, newRiderName: 'Vijay Bangalore Rider' });
it('finds the order by its bookingno, which is the only id it has', () => {
const rider = extractRiders(recovered())[0];
expect(rider).toMatchObject({ rider_id: 12, rider_name: 'Vijay Bangalore Rider' });
expect(rider.orders).toHaveLength(1);
});
it('clears the refusal, so the banner and the Reconcile list empty out', () => {
expect(extractUnassigned(recovered())).toEqual([]);
expect(assignmentSummary(recovered())).toMatchObject({ assigned: 1, unassigned: 0, failed: false });
});
it('produces a commit payload carrying the real miler id', () => {
const [delivery] = flattenRiders(extractRiders(recovered()));
expect(delivery).toMatchObject({ orderid: ORDER_ID, bookingid: 1737, rider_id: 12, userid: 12 });
});
it('keeps the booking fields the commit needs', () => {
const [delivery] = flattenRiders(extractRiders(recovered()));
// `POST /admin/bookings/:id/assign-miler` is keyed on the booking, so
// losing bookingid here would make the order uncommittable.
expect(delivery.bookingid).toBe(1737);
expect(delivery.deliverycharges).toBe(56);
});
});
describe('whether the recovery still has to go through Reconcile', () => {
const assignTo = (preview, newRiderId) => ({
before: preview,
after: moveOrderInPreviewData(preview, { orderId: ORDER_ID, newRiderId, newRiderName: 'Vijay' })
});
it('does not, for a refused order given a miler with nothing else on', () => {
// The dead end this exists to break: reconcile-steps is the same service
// that had no riders, so gating Assign Orders on it would strand the
// order permanently.
const { before, after } = assignTo(LIVE_RUN, 12);
expect(editNeedsReconcile(before, after, { orderId: ORDER_ID, newRiderId: 12 })).toBe(false);
});
it('does, when the refused order is dropped into an existing route', () => {
const withPlan = {
...LIVE_RUN,
zones: [
{
...LIVE_RUN.zones[0],
riders: [{ rider_id: 12, rider_name: 'Vijay', orders: [{ orderid: 'DM-100001' }, { orderid: 'DM-100002' }] }]
}
]
};
const { before, after } = assignTo(withPlan, 12);
expect(editNeedsReconcile(before, after, { orderId: ORDER_ID, newRiderId: 12 })).toBe(true);
});
it('does, for an ordinary rider-to-rider move inside the plan', () => {
const planned = {
zones: [
{
zone_name: 'North West',
riders: [
{ rider_id: 7, rider_name: 'Rajan', orders: [{ orderid: 'DM-100001' }] },
{ rider_id: 12, rider_name: 'Vijay', orders: [{ orderid: 'DM-100002' }] }
]
}
]
};
const after = moveOrderInPreviewData(planned, {
orderId: 'DM-100001',
newRiderId: 12,
newRiderName: 'Vijay'
});
expect(editNeedsReconcile(planned, after, { orderId: 'DM-100001', newRiderId: 12 })).toBe(true);
});
});

View File

@@ -0,0 +1,378 @@
import {
assignmentSummary,
extractRiders,
extractUnassigned,
moveOrderInPreviewData,
withUnassignedZones,
UNASSIGNED_RIDER_ID
} from '@/lib/dispatchPreview';
/**
* The orders the optimiser refuses, and how the console reports them.
*
* The bug this covers: pressing "AI assign" produced a green "Orders
* optimised" toast over an empty preview board. The optimiser had not
* failed silently — it answered 200 with every order in
* `zones[].unassigned_orders[]`, each carrying an `unassigned_reason` — and
* nothing in the console read that key. `grep unassigned Preview.jsx`
* returned zero matches.
*
* REAL_RESPONSE below is not invented. It is the live reply from
* routes.workolik.com on 2026-09-22 to a single Coimbatore delivery, trimmed
* to the fields under test. That matters: the whole defect was a shape
* assumption, so a fixture written from the same assumption would pass while
* the page stayed broken.
*/
const REAL_RESPONSE = {
code: 200,
status: true,
zone_summary: [
{
zone_name: 'North East',
total_orders: 1,
assigned_orders: 0,
unassigned_orders_count: 1,
active_riders_count: 0
}
],
zones: [
{
zone_name: 'North East',
total_orders: 1,
active_riders_count: 0,
assigned_orders: 0,
unassigned_orders_count: 1,
riders: [],
unassigned_orders: [
{
deliveryid: 0,
bookingid: 901,
bookingno: 'DM-TEST-1',
orderstatus: 'pending',
deliverycharges: 120,
deliverylocation: 'Gandhipuram, Coimbatore',
unassigned_reason: 'No riders found (check partner online status).'
}
]
}
]
};
/** The other real shape: empty input gives `details: {}` — an object. */
const EMPTY_INPUT_RESPONSE = {
code: 200,
status: true,
details: {},
message: 'No orders found to assign.',
meta: { active_riders_count: 0 }
};
const assignedZone = (orders) => ({
zones: [{ zone_name: 'North East', riders: [{ rider_id: 7, rider_name: 'Rajan A', orders }] }]
});
describe('extractUnassigned', () => {
it('finds the orders the live optimiser refused', () => {
const rows = extractUnassigned(REAL_RESPONSE);
expect(rows).toHaveLength(1);
expect(rows[0].bookingno).toBe('DM-TEST-1');
});
it('keeps the reason, which is the whole point', () => {
expect(extractUnassigned(REAL_RESPONSE)[0].unassigned_reason).toBe(
'No riders found (check partner online status).'
);
});
it('tags each order with the zone it came from', () => {
expect(extractUnassigned(REAL_RESPONSE)[0].zone_name).toBe('North East');
});
it('gives every order a stable orderid for the list key', () => {
expect(extractUnassigned(REAL_RESPONSE)[0].orderid).toBe('DM-TEST-1');
});
it('falls back through the id spellings the services disagree on', () => {
const rows = extractUnassigned({
zones: [{ unassigned_orders: [{ booking_id: 55 }, { order_id: 66 }, { id: 77 }] }]
});
expect(rows.map((r) => r.orderid)).toEqual(['55', '66', '77']);
});
it('synthesises an id rather than dropping an order that has none', () => {
// A row with no id at all must still reach the operator; a missing key
// is not a reason to lose an order they are waiting on.
const rows = extractUnassigned({ zones: [{ unassigned_orders: [{ deliverylocation: 'Somewhere' }] }] });
expect(rows).toHaveLength(1);
expect(rows[0].orderid).toBeTruthy();
});
it('says so when no reason was given, rather than showing blank', () => {
const rows = extractUnassigned({ zones: [{ unassigned_orders: [{ bookingno: 'X' }] }] });
expect(rows[0].unassigned_reason).toBe('No reason given');
});
it('accepts the alternative reason spellings', () => {
const rows = extractUnassigned({
zones: [{ unassigned_orders: [{ bookingno: 'A', reason: 'r' }, { bookingno: 'B', message: 'm' }] }]
});
expect(rows.map((r) => r.unassigned_reason)).toEqual(['r', 'm']);
});
it('reads a top-level list too, not only per-zone', () => {
// The envelope is demonstrably not fixed — the empty-input reply has no
// zones at all — and an order in neither place is one nobody hears about.
const rows = extractUnassigned({ unassigned_orders: [{ bookingno: 'TOP' }] });
expect(rows.map((r) => r.bookingno)).toEqual(['TOP']);
});
it('collects across several zones and de-duplicates', () => {
const rows = extractUnassigned({
zones: [
{ zone_name: 'A', unassigned_orders: [{ bookingno: 'ONE' }] },
{ zone_name: 'B', unassigned_orders: [{ bookingno: 'TWO' }, { bookingno: 'ONE' }] }
]
});
expect(rows.map((r) => r.bookingno)).toEqual(['ONE', 'TWO']);
});
it('is empty, not a crash, for the shapes that carry nothing', () => {
expect(extractUnassigned(null)).toEqual([]);
expect(extractUnassigned(undefined)).toEqual([]);
expect(extractUnassigned({})).toEqual([]);
// `details: {}` is an object here, not an array — the real empty reply.
expect(extractUnassigned(EMPTY_INPUT_RESPONSE)).toEqual([]);
expect(extractUnassigned({ zones: [] })).toEqual([]);
expect(extractUnassigned({ zones: [{ riders: [] }] })).toEqual([]);
});
});
describe('assignmentSummary', () => {
it('reports a total refusal as failed — the bug in one assertion', () => {
const s = assignmentSummary(REAL_RESPONSE);
expect(s).toMatchObject({ assigned: 0, unassigned: 1, total: 1, riders: 0, failed: true });
});
it('surfaces the reason so the toast can name it', () => {
expect(assignmentSummary(REAL_RESPONSE).reasons).toEqual([
{ reason: 'No riders found (check partner online status).', count: 1 }
]);
});
it('is NOT failed when everything was assigned', () => {
const s = assignmentSummary(assignedZone([{ bookingno: 'A' }, { bookingno: 'B' }]));
expect(s).toMatchObject({ assigned: 2, unassigned: 0, riders: 1, failed: false });
});
it('is NOT failed on a partial run — some work did land', () => {
const preview = {
zones: [
{
zone_name: 'North East',
riders: [{ rider_id: 7, rider_name: 'Rajan A', orders: [{ bookingno: 'A' }] }],
unassigned_orders: [{ bookingno: 'B', unassigned_reason: 'Out of range' }]
}
]
};
const s = assignmentSummary(preview);
expect(s).toMatchObject({ assigned: 1, unassigned: 1, total: 2, failed: false });
});
it('ranks reasons by frequency, so one line explains a run', () => {
const preview = {
zones: [
{
unassigned_orders: [
{ bookingno: 'A', unassigned_reason: 'No riders found' },
{ bookingno: 'B', unassigned_reason: 'Out of range' },
{ bookingno: 'C', unassigned_reason: 'No riders found' }
]
}
]
};
expect(assignmentSummary(preview).reasons).toEqual([
{ reason: 'No riders found', count: 2 },
{ reason: 'Out of range', count: 1 }
]);
});
it('is not failed when there was simply no work — that is not a failure', () => {
// An empty run must not shout. `failed` means "was given orders and
// placed none", not "had nothing to do".
expect(assignmentSummary(EMPTY_INPUT_RESPONSE)).toMatchObject({
assigned: 0,
unassigned: 0,
total: 0,
failed: false
});
expect(assignmentSummary(null)).toMatchObject({ total: 0, failed: false });
});
it('counts orders across multiple riders', () => {
const preview = {
zones: [
{
riders: [
{ rider_id: 1, rider_name: 'A', orders: [{ bookingno: 'A1' }, { bookingno: 'A2' }] },
{ rider_id: 2, rider_name: 'B', orders: [{ bookingno: 'B1' }] }
]
}
]
};
expect(assignmentSummary(preview)).toMatchObject({ assigned: 3, riders: 2, failed: false });
});
});
describe('withUnassignedZones', () => {
it('puts the refused order on the board, under its own zone', () => {
const folded = withUnassignedZones(REAL_RESPONSE);
expect(folded.zones).toHaveLength(1);
expect(folded.zones[0].zone_name).toBe('North East');
expect(folded.zones[0].riders).toHaveLength(1);
expect(folded.zones[0].riders[0].orders).toHaveLength(1);
});
it('files it under the key Dispatch skips, so no miler is invented', () => {
const bucket = withUnassignedZones(REAL_RESPONSE).zones[0].riders[0];
expect(bucket.rider_id).toBe(UNASSIGNED_RIDER_ID);
expect(bucket.rider_name).toBe('Unassigned');
});
it('carries the reason through to the order on the board', () => {
const order = withUnassignedZones(REAL_RESPONSE).zones[0].riders[0].orders[0];
expect(order.unassigned_reason).toBe('No riders found (check partner online status).');
expect(order.orderid).toBe('DM-TEST-1');
});
it('numbers the orders so the step chips have something to show', () => {
const orders = withUnassignedZones({
zones: [{ zone_name: 'Z', unassigned_orders: [{ bookingno: 'A' }, { bookingno: 'B' }] }]
}).zones[0].riders[0].orders;
expect(orders.map((o) => o.step)).toEqual([1, 2]);
expect(orders.every((o) => o.trip_number === 1)).toBe(true);
});
it('keeps the riders that DID get work, alongside the bucket', () => {
const folded = withUnassignedZones({
zones: [
{
zone_name: 'North East',
riders: [{ rider_id: 7, rider_name: 'Rajan A', orders: [{ bookingno: 'A' }] }],
unassigned_orders: [{ bookingno: 'B', unassigned_reason: 'Out of range' }]
}
]
});
expect(folded.zones[0].riders.map((r) => r.rider_id)).toEqual([7, UNASSIGNED_RIDER_ID]);
});
it('creates a zone for a top-level refusal that belongs to none', () => {
const folded = withUnassignedZones({ unassigned_orders: [{ bookingno: 'TOP' }] });
expect(folded.zones).toHaveLength(1);
expect(folded.zones[0].zone_name).toBe('Unzoned');
});
it('is idempotent — folding twice does not double the bucket', () => {
const once = withUnassignedZones(REAL_RESPONSE);
const twice = withUnassignedZones(once);
expect(twice.zones[0].riders).toHaveLength(1);
expect(twice.zones[0].riders[0].orders).toHaveLength(1);
});
it('returns the input untouched when nothing was refused', () => {
const clean = assignedZone([{ bookingno: 'A' }]);
expect(withUnassignedZones(clean)).toBe(clean);
expect(withUnassignedZones(EMPTY_INPUT_RESPONSE)).toBe(EMPTY_INPUT_RESPONSE);
expect(withUnassignedZones(null)).toBeNull();
});
it('does not mutate the response it was given', () => {
const before = JSON.stringify(REAL_RESPONSE);
withUnassignedZones(REAL_RESPONSE);
expect(JSON.stringify(REAL_RESPONSE)).toBe(before);
});
});
describe('assigning a rider to an order the optimiser refused', () => {
/**
* This is the recovery path the board now offers. Before the refused orders
* were drawn, the "change rider" button was unreachable for them; drawing
* them without this would put a button on screen that silently did nothing.
*/
const move = (preview) =>
moveOrderInPreviewData(preview, { orderId: 'DM-TEST-1', newRiderId: 7, newRiderName: 'Rajan A' });
it('takes the order out of the refusal list', () => {
const moved = move(REAL_RESPONSE);
expect(moved.zones[0].unassigned_orders).toHaveLength(0);
expect(extractUnassigned(moved)).toEqual([]);
});
it('puts it on the chosen rider, in the zone it came from', () => {
const moved = move(REAL_RESPONSE);
const rider = moved.zones[0].riders.find((r) => r.rider_id === 7);
expect(rider.orders.map((o) => o.bookingno)).toEqual(['DM-TEST-1']);
expect(moved.zones[0].zone_name).toBe('North East');
});
it('stamps the orderid the board keyed it by, so the commit agrees', () => {
const rider = move(REAL_RESPONSE).zones[0].riders.find((r) => r.rider_id === 7);
expect(rider.orders[0].orderid).toBe('DM-TEST-1');
});
it('turns the run from failed into assigned', () => {
expect(assignmentSummary(REAL_RESPONSE)).toMatchObject({ assigned: 0, unassigned: 1, failed: true });
expect(assignmentSummary(move(REAL_RESPONSE))).toMatchObject({ assigned: 1, unassigned: 0, failed: false });
});
it('adds to a rider who already has work rather than duplicating them', () => {
const preview = {
zones: [
{
zone_name: 'North East',
riders: [{ rider_id: 7, rider_name: 'Rajan A', orders: [{ orderid: 'A', bookingno: 'A' }] }],
unassigned_orders: [{ bookingno: 'DM-TEST-1' }]
}
]
};
const moved = move(preview);
expect(moved.zones[0].riders).toHaveLength(1);
expect(moved.zones[0].riders[0].orders.map((o) => o.orderid)).toEqual(['A', 'DM-TEST-1']);
});
it('handles a zone that holds refusals and no riders array at all', () => {
const moved = move({ zones: [{ zone_name: 'North East', unassigned_orders: [{ bookingno: 'DM-TEST-1' }] }] });
expect(moved.zones[0].riders[0].rider_id).toBe(7);
expect(moved.zones[0].unassigned_orders).toHaveLength(0);
});
it('handles a refusal reported at the top level, with no zones', () => {
const moved = move({ unassigned_orders: [{ bookingno: 'DM-TEST-1' }] });
expect(moved.zones).toHaveLength(1);
expect(moved.zones[0].riders[0].orders[0].orderid).toBe('DM-TEST-1');
expect(moved.unassigned_orders).toHaveLength(0);
});
it('never loses the order — it is on a rider or still refused, never neither', () => {
[REAL_RESPONSE, { unassigned_orders: [{ bookingno: 'DM-TEST-1' }] }].forEach((preview) => {
const moved = move(preview);
const onRider = extractRiders(moved).flatMap((r) => r.orders).length;
expect(onRider + extractUnassigned(moved).length).toBe(1);
});
});
it('leaves an order it cannot find alone, rather than moving the wrong one', () => {
const moved = moveOrderInPreviewData(REAL_RESPONSE, {
orderId: 'NOT-HERE',
newRiderId: 7,
newRiderName: 'Rajan A'
});
expect(extractUnassigned(moved)).toHaveLength(1);
expect(extractRiders(moved)).toEqual([]);
});
it('keeps the synthetic bucket out of the commit payload', () => {
// Folded data must never reach the payload builder as a rider.
expect(extractRiders(withUnassignedZones(REAL_RESPONSE))).toEqual([]);
});
});