updates on the ui changes and new changes as well

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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