updates on the ui changes and new changes as well
This commit is contained in:
@@ -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
22
src/lib/dateRange.js
Normal 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 || '' };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
202
src/pages/doormile/settings/agentStudio/AgentPlayground.jsx
Normal file
202
src/pages/doormile/settings/agentStudio/AgentPlayground.jsx
Normal 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">> 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>
|
||||
);
|
||||
}
|
||||
289
src/pages/doormile/settings/agentStudio/AgentScopeNavigator.jsx
Normal file
289
src/pages/doormile/settings/agentStudio/AgentScopeNavigator.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
src/pages/doormile/settings/agentStudio/AgentStudio.jsx
Normal file
213
src/pages/doormile/settings/agentStudio/AgentStudio.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
src/pages/doormile/settings/agentStudio/CreateSkillModal.jsx
Normal file
227
src/pages/doormile/settings/agentStudio/CreateSkillModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
src/pages/doormile/settings/agentStudio/SkillCard.jsx
Normal file
150
src/pages/doormile/settings/agentStudio/SkillCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
src/pages/doormile/settings/agentStudio/SkillCardsGrid.jsx
Normal file
238
src/pages/doormile/settings/agentStudio/SkillCardsGrid.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
247
src/pages/doormile/settings/agentStudio/SkillDetailsDrawer.jsx
Normal file
247
src/pages/doormile/settings/agentStudio/SkillDetailsDrawer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
src/pages/doormile/settings/agentStudio/ToolRegistryView.jsx
Normal file
198
src/pages/doormile/settings/agentStudio/ToolRegistryView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
src/pages/doormile/settings/agentStudio/agentRegistryData.js
Normal file
241
src/pages/doormile/settings/agentStudio/agentRegistryData.js
Normal 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);
|
||||
}
|
||||
}
|
||||
124
tests/ds/datePicker.test.jsx
Normal file
124
tests/ds/datePicker.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
204
tests/integration/agentStudio.test.jsx
Normal file
204
tests/integration/agentStudio.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
54
tests/lib/dateRange.test.js
Normal file
54
tests/lib/dateRange.test.js
Normal 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: '' });
|
||||
});
|
||||
});
|
||||
211
tests/lib/dispatchPreviewLiveRun.test.js
Normal file
211
tests/lib/dispatchPreviewLiveRun.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
378
tests/lib/dispatchPreviewUnassigned.test.js
Normal file
378
tests/lib/dispatchPreviewUnassigned.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user