updates on the address while creasating the orders and security updates as well
This commit is contained in:
@@ -4,75 +4,441 @@ import { Autocomplete, TextField, CircularProgress } from '@mui/material';
|
||||
import { debounce } from '@mui/material/utils';
|
||||
import { OpenLocationCode } from 'open-location-code';
|
||||
|
||||
// Free OSM Nominatim geocoder — replaces Google Places Autocomplete /
|
||||
// react-geocode across the console. No API key required. Nominatim's usage
|
||||
// policy caps the public endpoint at ~1 req/sec, so every caller here is
|
||||
// debounced (500ms) and cancels stale in-flight predictions.
|
||||
// Nagercoil & Kanyakumari District geographic boundary constraints
|
||||
const NAGERCOIL_COORDS = { lat: 8.1833, lng: 77.4119 };
|
||||
const KANYAKUMARI_BOUNDS = {
|
||||
minLat: 8.04,
|
||||
maxLat: 8.42,
|
||||
minLng: 77.15,
|
||||
maxLng: 77.58
|
||||
};
|
||||
const KANYAKUMARI_BBOX_PHOTON = '77.15,8.04,77.58,8.42'; // minLon,minLat,maxLon,maxLat
|
||||
const KANYAKUMARI_VIEWBOX_NOMINATIM = '77.15,8.42,77.58,8.04'; // left,top,right,bottom
|
||||
|
||||
const PHOTON_SEARCH_URL = 'https://photon.komoot.io/api';
|
||||
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
|
||||
const NOMINATIM_REVERSE_URL = 'https://nominatim.openstreetmap.org/reverse';
|
||||
|
||||
// Strict boundary validator ensuring suggestions never show outside Kanyakumari District
|
||||
export const isWithinKanyakumariDistrict = (lat, lon) => {
|
||||
const nLat = Number(lat);
|
||||
const nLon = Number(lon);
|
||||
if (!Number.isFinite(nLat) || !Number.isFinite(nLon)) return false;
|
||||
return (
|
||||
nLat >= KANYAKUMARI_BOUNDS.minLat &&
|
||||
nLat <= KANYAKUMARI_BOUNDS.maxLat &&
|
||||
nLon >= KANYAKUMARI_BOUNDS.minLng &&
|
||||
nLon <= KANYAKUMARI_BOUNDS.maxLng
|
||||
);
|
||||
};
|
||||
|
||||
// Curated Nagercoil & Kanyakumari local areas dictionary for instant 0ms sublocality match
|
||||
const NAGERCOIL_LOCALITIES = [
|
||||
{
|
||||
name: 'Kurusady',
|
||||
display_name: 'Kurusady, Ramanputhoor Road, Nagercoil, Tamil Nadu 629004, India',
|
||||
lat: '8.1691',
|
||||
lon: '77.4247',
|
||||
suburb: 'Kurusady',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629004'
|
||||
},
|
||||
{
|
||||
name: 'Kurusady Junction',
|
||||
display_name: 'Kurusady Junction, Chettikulam - Ramanputhoor Rd, Nagercoil, Tamil Nadu 629004, India',
|
||||
lat: '8.1695',
|
||||
lon: '77.4251',
|
||||
suburb: 'Kurusady',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629004'
|
||||
},
|
||||
{
|
||||
name: 'Ramanputhoor',
|
||||
display_name: 'Ramanputhoor, Nagercoil, Kanyakumari District, Tamil Nadu 629004, India',
|
||||
lat: '8.1685',
|
||||
lon: '77.4265',
|
||||
suburb: 'Ramanputhoor',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629004'
|
||||
},
|
||||
{
|
||||
name: 'Vadasery',
|
||||
display_name: 'Vadasery, Christopher Bus Stand Area, Nagercoil, Tamil Nadu 629001, India',
|
||||
lat: '8.1906',
|
||||
lon: '77.4325',
|
||||
suburb: 'Vadasery',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629001'
|
||||
},
|
||||
{
|
||||
name: 'Chettikulam',
|
||||
display_name: 'Chettikulam, Nagercoil, Kanyakumari District, Tamil Nadu 629002, India',
|
||||
lat: '8.1755',
|
||||
lon: '77.4290',
|
||||
suburb: 'Chettikulam',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629002'
|
||||
},
|
||||
{
|
||||
name: 'Chettikulam Junction',
|
||||
display_name: 'Chettikulam Junction, Nagercoil, Tamil Nadu 629002, India',
|
||||
lat: '8.1748',
|
||||
lon: '77.4316',
|
||||
suburb: 'Chettikulam',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629002'
|
||||
},
|
||||
{
|
||||
name: 'Parvathipuram',
|
||||
display_name: 'Parvathipuram, Nagercoil, Kanyakumari District, Tamil Nadu 629003, India',
|
||||
lat: '8.1950',
|
||||
lon: '77.4080',
|
||||
suburb: 'Parvathipuram',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629003'
|
||||
},
|
||||
{
|
||||
name: 'Asaripallam',
|
||||
display_name: 'Asaripallam, Medical College Area, Nagercoil, Tamil Nadu 629201, India',
|
||||
lat: '8.1850',
|
||||
lon: '77.3970',
|
||||
suburb: 'Asaripallam',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629201'
|
||||
},
|
||||
{
|
||||
name: 'Vetturnimadam',
|
||||
display_name: 'Vetturnimadam, Nagercoil, Kanyakumari District, Tamil Nadu 629003, India',
|
||||
lat: '8.1920',
|
||||
lon: '77.4190',
|
||||
suburb: 'Vetturnimadam',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629003'
|
||||
},
|
||||
{
|
||||
name: 'Kottar',
|
||||
display_name: 'Kottar, Railway Station Road, Nagercoil, Tamil Nadu 629002, India',
|
||||
lat: '8.1720',
|
||||
lon: '77.4360',
|
||||
suburb: 'Kottar',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629002'
|
||||
},
|
||||
{
|
||||
name: 'Meenakshipuram',
|
||||
display_name: 'Meenakshipuram, Anna Bus Stand Area, Nagercoil, Tamil Nadu 629001, India',
|
||||
lat: '8.1830',
|
||||
lon: '77.4310',
|
||||
suburb: 'Meenakshipuram',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629001'
|
||||
},
|
||||
{
|
||||
name: 'Suchindram',
|
||||
display_name: 'Suchindram, Temple Road, Kanyakumari District, Tamil Nadu 629704, India',
|
||||
lat: '8.1560',
|
||||
lon: '77.4640',
|
||||
suburb: 'Suchindram',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629704'
|
||||
},
|
||||
{
|
||||
name: 'Chunkankadai',
|
||||
display_name: 'Chunkankadai, St. Xaviers Catholic College Area, Nagercoil, Tamil Nadu 629003, India',
|
||||
lat: '8.2040',
|
||||
lon: '77.3870',
|
||||
suburb: 'Chunkankadai',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629003'
|
||||
},
|
||||
{
|
||||
name: 'Putheri',
|
||||
display_name: 'Putheri, Nagercoil, Kanyakumari District, Tamil Nadu 629001, India',
|
||||
lat: '8.2060',
|
||||
lon: '77.4300',
|
||||
suburb: 'Putheri',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629001'
|
||||
},
|
||||
{
|
||||
name: 'Ozhuginasery',
|
||||
display_name: 'Ozhuginasery, Nagercoil, Tamil Nadu 629001, India',
|
||||
lat: '8.1880',
|
||||
lon: '77.4410',
|
||||
suburb: 'Ozhuginasery',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629001'
|
||||
},
|
||||
{
|
||||
name: 'Elankadai',
|
||||
display_name: 'Elankadai, Kottar, Nagercoil, Tamil Nadu 629002, India',
|
||||
lat: '8.1630',
|
||||
lon: '77.4420',
|
||||
suburb: 'Elankadai',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629002'
|
||||
},
|
||||
{
|
||||
name: 'Tower Junction',
|
||||
display_name: 'Tower Junction, Clock Tower Area, Nagercoil, Tamil Nadu 629001, India',
|
||||
lat: '8.1810',
|
||||
lon: '77.4300',
|
||||
suburb: 'Nagercoil Town',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629001'
|
||||
},
|
||||
{
|
||||
name: 'Derik Junction',
|
||||
display_name: 'Derik Junction, Cape Road, Nagercoil, Tamil Nadu 629001, India',
|
||||
lat: '8.1760',
|
||||
lon: '77.4350',
|
||||
suburb: 'Nagercoil Town',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629001'
|
||||
},
|
||||
{
|
||||
name: 'Collectorate',
|
||||
display_name: 'Collectorate Office Area, Nagercoil, Tamil Nadu 629001, India',
|
||||
lat: '8.1850',
|
||||
lon: '77.4240',
|
||||
suburb: 'Collectorate',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629001'
|
||||
},
|
||||
{
|
||||
name: 'Marthandam',
|
||||
display_name: 'Marthandam, Kanyakumari District, Tamil Nadu 629165, India',
|
||||
lat: '8.3033',
|
||||
lon: '77.2286',
|
||||
suburb: 'Marthandam',
|
||||
city: 'Marthandam',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629165'
|
||||
},
|
||||
{
|
||||
name: 'Thuckalay',
|
||||
display_name: 'Thuckalay, Kanyakumari District, Tamil Nadu 629175, India',
|
||||
lat: '8.2500',
|
||||
lon: '77.3300',
|
||||
suburb: 'Thuckalay',
|
||||
city: 'Thuckalay',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629175'
|
||||
},
|
||||
{
|
||||
name: 'Colachel',
|
||||
display_name: 'Colachel, Kanyakumari District, Tamil Nadu 629251, India',
|
||||
lat: '8.1755',
|
||||
lon: '77.2582',
|
||||
suburb: 'Colachel',
|
||||
city: 'Colachel',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629251'
|
||||
},
|
||||
{
|
||||
name: 'Karungal',
|
||||
display_name: 'Karungal, Kanyakumari District, Tamil Nadu 629157, India',
|
||||
lat: '8.2580',
|
||||
lon: '77.2450',
|
||||
suburb: 'Karungal',
|
||||
city: 'Karungal',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629157'
|
||||
},
|
||||
{
|
||||
name: 'Thingalnagar',
|
||||
display_name: 'Thingalnagar (Monday Market), Kanyakumari District, Tamil Nadu 629802, India',
|
||||
lat: '8.2120',
|
||||
lon: '77.2980',
|
||||
suburb: 'Thingalnagar',
|
||||
city: 'Thingalnagar',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629802'
|
||||
},
|
||||
{
|
||||
name: 'Monday Market',
|
||||
display_name: 'Monday Market, Thingalnagar, Kanyakumari District, Tamil Nadu 629802, India',
|
||||
lat: '8.2120',
|
||||
lon: '77.2980',
|
||||
suburb: 'Thingalnagar',
|
||||
city: 'Thingalnagar',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629802'
|
||||
},
|
||||
{
|
||||
name: 'Kanyakumari',
|
||||
display_name: 'Kanyakumari, Beach Road, Tamil Nadu 629702, India',
|
||||
lat: '8.0883',
|
||||
lon: '77.5385',
|
||||
suburb: 'Kanyakumari',
|
||||
city: 'Kanyakumari',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629702'
|
||||
},
|
||||
{
|
||||
name: 'Thovalai',
|
||||
display_name: 'Thovalai, Flower Market Area, Kanyakumari District, Tamil Nadu 629302, India',
|
||||
lat: '8.2430',
|
||||
lon: '77.5020',
|
||||
suburb: 'Thovalai',
|
||||
city: 'Thovalai',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629302'
|
||||
},
|
||||
{
|
||||
name: 'Boothapandi',
|
||||
display_name: 'Boothapandi, Kanyakumari District, Tamil Nadu 629852, India',
|
||||
lat: '8.2610',
|
||||
lon: '77.4640',
|
||||
suburb: 'Boothapandi',
|
||||
city: 'Boothapandi',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629852'
|
||||
},
|
||||
{
|
||||
name: 'Aralvaimozhi',
|
||||
display_name: 'Aralvaimozhi, Kanyakumari District, Tamil Nadu 629301, India',
|
||||
lat: '8.2470',
|
||||
lon: '77.5250',
|
||||
suburb: 'Aralvaimozhi',
|
||||
city: 'Aralvaimozhi',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629301'
|
||||
},
|
||||
{
|
||||
name: 'Anjugramam',
|
||||
display_name: 'Anjugramam, Kanyakumari District, Tamil Nadu 629401, India',
|
||||
lat: '8.1360',
|
||||
lon: '77.5280',
|
||||
suburb: 'Anjugramam',
|
||||
city: 'Anjugramam',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629401'
|
||||
},
|
||||
{
|
||||
name: 'Agasteeswaram',
|
||||
display_name: 'Agasteeswaram, Kanyakumari District, Tamil Nadu 629701, India',
|
||||
lat: '8.1180',
|
||||
lon: '77.5120',
|
||||
suburb: 'Agasteeswaram',
|
||||
city: 'Agasteeswaram',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629701'
|
||||
},
|
||||
{
|
||||
name: 'Kaliyakkavilai',
|
||||
display_name: 'Kaliyakkavilai, Kanyakumari District, Tamil Nadu 629153, India',
|
||||
lat: '8.3240',
|
||||
lon: '77.1620',
|
||||
suburb: 'Kaliyakkavilai',
|
||||
city: 'Kaliyakkavilai',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629153'
|
||||
},
|
||||
{
|
||||
name: 'Kulasekharam',
|
||||
display_name: 'Kulasekharam, Kanyakumari District, Tamil Nadu 629161, India',
|
||||
lat: '8.3580',
|
||||
lon: '77.3000',
|
||||
suburb: 'Kulasekharam',
|
||||
city: 'Kulasekharam',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629161'
|
||||
},
|
||||
{
|
||||
name: 'Padmanabhapuram',
|
||||
display_name: 'Padmanabhapuram, Palace Area, Kanyakumari District, Tamil Nadu 629175, India',
|
||||
lat: '8.2510',
|
||||
lon: '77.3270',
|
||||
suburb: 'Padmanabhapuram',
|
||||
city: 'Padmanabhapuram',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629175'
|
||||
}
|
||||
];
|
||||
|
||||
// Builds a Google-Places-shaped `address_components` array (each entry
|
||||
// `{ long_name, short_name, types: [] }`) from Nominatim's flat `address`
|
||||
// object, so existing call sites' `place.address_components.forEach(...)`
|
||||
// type-switch parsing keeps working unchanged.
|
||||
// `{ long_name, short_name, types: [] }`) from flat or Nominatim address objects.
|
||||
const buildAddressComponents = (addr = {}) => {
|
||||
const components = [];
|
||||
const push = (longName, types) => {
|
||||
if (longName) components.push({ long_name: longName, short_name: longName, types });
|
||||
if (longName) components.push({ long_name: String(longName).trim(), short_name: String(longName).trim(), types });
|
||||
};
|
||||
push(addr.house_number, ['street_number']);
|
||||
push(addr.road || addr.pedestrian, ['route']);
|
||||
push(addr.suburb || addr.neighbourhood || addr.quarter, ['sublocality_level_1', 'sublocality']);
|
||||
push(addr.city_district || addr.county, ['administrative_area_level_3']);
|
||||
push(addr.city || addr.town || addr.village, ['locality']);
|
||||
push(addr.state, ['administrative_area_level_1']);
|
||||
push(addr.country, ['country']);
|
||||
push(addr.postcode, ['postal_code']);
|
||||
push(addr.house_number || addr.housenumber, ['street_number']);
|
||||
push(addr.road || addr.street || addr.pedestrian, ['route']);
|
||||
push(addr.suburb || addr.neighbourhood || addr.quarter || addr.district, [
|
||||
'sublocality_level_1',
|
||||
'sublocality',
|
||||
'administrative_area_level_3'
|
||||
]);
|
||||
push(addr.city_district || addr.county, ['administrative_area_level_2']);
|
||||
push(addr.city || addr.town || addr.village || 'Nagercoil', ['locality']);
|
||||
push(addr.state || 'Tamil Nadu', ['administrative_area_level_1']);
|
||||
push(addr.country || 'India', ['country']);
|
||||
push(addr.postcode || addr.postal_code, ['postal_code']);
|
||||
return components;
|
||||
};
|
||||
|
||||
// Shapes a Nominatim result close enough to a Google Places `place` object
|
||||
// (formatted_address, geometry.location.lat()/lng(), address_components)
|
||||
// that call sites need only mechanical edits, not rewrites.
|
||||
export const toPlace = (result) => ({
|
||||
formatted_address: result.display_name,
|
||||
name: result.display_name?.split(',')[0] || '',
|
||||
geometry: {
|
||||
location: {
|
||||
lat: () => parseFloat(result.lat),
|
||||
lng: () => parseFloat(result.lon)
|
||||
}
|
||||
},
|
||||
address_components: buildAddressComponents(result.address)
|
||||
});
|
||||
// Shapes a geocoder result to a Google Places `place` object
|
||||
export const toPlace = (result) => {
|
||||
const lat = parseFloat(result.lat || result.latitude || 0);
|
||||
const lng = parseFloat(result.lon || result.lng || result.longitude || 0);
|
||||
const name = result.name || result.display_name?.split(',')[0] || '';
|
||||
const addr = result.address || {
|
||||
suburb: result.suburb || '',
|
||||
city: result.city || 'Nagercoil',
|
||||
state: result.state || 'Tamil Nadu',
|
||||
postcode: result.postcode || ''
|
||||
};
|
||||
|
||||
// Nominatim requests have no built-in timeout — a stalled connection (proxy
|
||||
// silently dropping packets, an overloaded public endpoint) would otherwise
|
||||
// leave `fetch` pending forever, so callers see the spinner spin with nothing
|
||||
// ever happening. This bounds every request to REQUEST_TIMEOUT_MS.
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
let formatted = result.display_name || name;
|
||||
if (name && formatted.startsWith(name + ', ')) {
|
||||
formatted = formatted.slice(name.length + 2);
|
||||
}
|
||||
|
||||
return {
|
||||
formatted_address: formatted,
|
||||
name: name,
|
||||
geometry: {
|
||||
location: {
|
||||
lat: () => (Number.isFinite(lat) ? lat : 0),
|
||||
lng: () => (Number.isFinite(lng) ? lng : 0)
|
||||
}
|
||||
},
|
||||
address_components: buildAddressComponents(addr)
|
||||
};
|
||||
};
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 6000;
|
||||
const fetchWithTimeout = (url, options) => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timer));
|
||||
};
|
||||
|
||||
// Nominatim (and Google Places, previously) both do full-text search against
|
||||
// a database of named places — neither understands Plus Codes (e.g.
|
||||
// "5CM7+8Q7"), because a Plus Code isn't a name to look up, it's lat/lng
|
||||
// encoded directly into the string. Decoding it is a pure local computation
|
||||
// via the Open Location Code algorithm, so this resolves reliably even where
|
||||
// OSM has zero data for the area (small towns, unlisted businesses).
|
||||
const olc = new OpenLocationCode();
|
||||
const PLUS_CODE_CHARS = '23456789CFGHJMPQRVWX';
|
||||
const PLUS_CODE_RE = new RegExp(`[${PLUS_CODE_CHARS}]{2,8}\\+[${PLUS_CODE_CHARS}]{2,3}`, 'i');
|
||||
|
||||
// A decoded Plus Code is just coordinates — on its own it has no street/
|
||||
// locality name to show the operator, only the code itself. Reverse-geocoding
|
||||
// those coordinates fills in the human-readable side (road, suburb, city...)
|
||||
// the same way a normal search result would, so the option in the dropdown
|
||||
// reads as an actual address instead of a bare code. Best-effort: if Nominatim
|
||||
// has no data for that exact point either, callers fall back to the code text.
|
||||
async function reverseGeocode(lat, lon) {
|
||||
try {
|
||||
const params = new URLSearchParams({ lat: String(lat), lon: String(lon), format: 'json', addressdetails: '1' });
|
||||
@@ -86,13 +452,17 @@ async function reverseGeocode(lat, lon) {
|
||||
}
|
||||
}
|
||||
|
||||
// Short codes (the common case — "5CM7+8Q7" rather than the full
|
||||
// "6JWV5CM7+8Q7") only encode an offset; recovering the actual location
|
||||
// needs a nearby reference point, which is what `bias` (the operator's
|
||||
// tenant/app location) provides. Returns a Nominatim-result-shaped object
|
||||
// (same shape `toPlace()` expects) so it can flow through the exact same
|
||||
// path as a real search hit, or null if no code is present / it can't be
|
||||
// resolved without a reference point.
|
||||
// Extracts door/house number prefix if present (e.g., '262,', 'No. 262,', 'Flat 4B,', 'D.No 12/4,', '262 Kurusady')
|
||||
export const extractDoorPrefix = (text) => {
|
||||
if (!text) return { doorNo: '', cleanQuery: '' };
|
||||
const trimmed = text.trim();
|
||||
const match = trimmed.match(/^((?:no\.?|d\.?no\.?|flat|door|plot|#)?\s*[\w\d\-\/]+)(?:,\s*|\s+)(.+)$/i);
|
||||
if (match) {
|
||||
return { doorNo: match[1].trim(), cleanQuery: match[2].trim() };
|
||||
}
|
||||
return { doorNo: '', cleanQuery: trimmed };
|
||||
};
|
||||
|
||||
const plusCodeToResult = async (text, bias) => {
|
||||
const match = text?.match(PLUS_CODE_RE);
|
||||
if (!match) return null;
|
||||
@@ -101,19 +471,14 @@ const plusCodeToResult = async (text, bias) => {
|
||||
try {
|
||||
let fullCode = code;
|
||||
if (olc.isShort(code)) {
|
||||
const refLat = Number(bias?.lat);
|
||||
const refLng = Number(bias?.lng);
|
||||
// (0,0) isn't a plausible tenant location — it's what `Number('')` and
|
||||
// `Number(undefined)` half-produce (empty string coerces to 0), so treat
|
||||
// it as "no reference point yet" rather than decoding against Null Island.
|
||||
if (!Number.isFinite(refLat) || !Number.isFinite(refLng) || (refLat === 0 && refLng === 0)) return null;
|
||||
const refLat = Number(bias?.lat) || NAGERCOIL_COORDS.lat;
|
||||
const refLng = Number(bias?.lng) || NAGERCOIL_COORDS.lng;
|
||||
if (!Number.isFinite(refLat) || !Number.isFinite(refLng)) return null;
|
||||
fullCode = olc.recoverNearest(code, refLat, refLng);
|
||||
}
|
||||
if (!olc.isFull(fullCode)) return null;
|
||||
const { latitudeCenter, longitudeCenter } = olc.decode(fullCode);
|
||||
// The decoded centroid is exact — keep it as the authoritative coordinate
|
||||
// even though `reverse` below carries its own (possibly snapped-to-nearest-
|
||||
// feature) lat/lon; only its display_name/address are what we want.
|
||||
if (!isWithinKanyakumariDistrict(latitudeCenter, longitudeCenter)) return null;
|
||||
const reverse = await reverseGeocode(latitudeCenter, longitudeCenter);
|
||||
return {
|
||||
...reverse,
|
||||
@@ -128,80 +493,202 @@ const plusCodeToResult = async (text, bias) => {
|
||||
}
|
||||
};
|
||||
|
||||
const buildParams = (query, bias) => {
|
||||
const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: '5' });
|
||||
// bias.lat/lng arrive as strings from the tenant-location API. `-` coerces to
|
||||
// number automatically, but `+` on a string concatenates instead of adding
|
||||
// (e.g. "8.17445" + 0.5 === "8.174450.5"), producing a malformed, non-numeric
|
||||
// viewbox that Nominatim rejects with 400 Bad Request on every single search.
|
||||
const lat = Number(bias?.lat);
|
||||
const lng = Number(bias?.lng);
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||
const d = 0.5; // ~55km box around the operator's zone — soft bias, not a hard filter
|
||||
params.set('viewbox', `${lng - d},${lat + d},${lng + d},${lat - d}`);
|
||||
// Search Photon (Fast, typo-tolerant OpenStreetMap geocoder strictly within Kanyakumari District)
|
||||
const searchPhoton = async (query, bias) => {
|
||||
try {
|
||||
const lat = Number(bias?.lat) || NAGERCOIL_COORDS.lat;
|
||||
const lng = Number(bias?.lng) || NAGERCOIL_COORDS.lng;
|
||||
const { doorNo, cleanQuery } = extractDoorPrefix(query);
|
||||
const searchQuery = cleanQuery || query;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: searchQuery,
|
||||
limit: '15',
|
||||
lang: 'en',
|
||||
lat: String(lat),
|
||||
lon: String(lng),
|
||||
bbox: KANYAKUMARI_BBOX_PHOTON
|
||||
});
|
||||
|
||||
const res = await fetchWithTimeout(`${PHOTON_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
if (!data?.features || !Array.isArray(data.features)) return [];
|
||||
|
||||
return data.features
|
||||
.filter((item) => {
|
||||
const [lon, latCoord] = item.geometry?.coordinates || [null, null];
|
||||
return isWithinKanyakumariDistrict(latCoord, lon);
|
||||
})
|
||||
.map((item, idx) => {
|
||||
const p = item.properties || {};
|
||||
const [lon, latCoord] = item.geometry?.coordinates || [null, null];
|
||||
const finalHouseNumber = doorNo || p.housenumber || '';
|
||||
|
||||
const parts = [
|
||||
finalHouseNumber ? `${finalHouseNumber}, ${p.name || ''}`.trim() : p.name,
|
||||
p.street ? `${!finalHouseNumber && p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '',
|
||||
p.district || p.suburb || p.neighbourhood || p.locality,
|
||||
p.city || 'Nagercoil',
|
||||
p.state || 'Tamil Nadu',
|
||||
p.postcode,
|
||||
'India'
|
||||
].filter(Boolean);
|
||||
|
||||
const uniqueParts = parts.filter((part, i, arr) => arr.indexOf(part) === i);
|
||||
const formatted = uniqueParts.join(', ');
|
||||
|
||||
return {
|
||||
place_id: `photon-${idx}-${latCoord}-${lon}`,
|
||||
name: finalHouseNumber ? `${finalHouseNumber}, ${p.name || ''}`.trim() : p.name || '',
|
||||
display_name: formatted || p.name || '',
|
||||
lat: String(latCoord),
|
||||
lon: String(lon),
|
||||
address: {
|
||||
house_number: finalHouseNumber,
|
||||
suburb: p.district || p.suburb || p.neighbourhood || p.locality || '',
|
||||
city: p.city || 'Nagercoil',
|
||||
state: p.state || 'Tamil Nadu',
|
||||
postcode: p.postcode || '',
|
||||
country: 'India'
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
const searchOnce = async (query, bias) => {
|
||||
const params = buildParams(query, bias);
|
||||
params.set('limit', '1');
|
||||
const res = await fetchWithTimeout(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return null;
|
||||
const results = await res.json();
|
||||
return results?.length ? results[0] : null;
|
||||
// Search Nominatim strictly bounded to Kanyakumari District
|
||||
const searchNominatim = async (query, bias) => {
|
||||
try {
|
||||
const { doorNo, cleanQuery } = extractDoorPrefix(query);
|
||||
const searchQuery = cleanQuery || query;
|
||||
const isLocalQuery =
|
||||
searchQuery.toLowerCase().includes('nagercoil') ||
|
||||
searchQuery.toLowerCase().includes('kanyakumari') ||
|
||||
searchQuery.toLowerCase().includes('kanniyakumari');
|
||||
const enrichedQuery = isLocalQuery ? searchQuery : `${searchQuery} Nagercoil`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: enrichedQuery,
|
||||
format: 'json',
|
||||
addressdetails: '1',
|
||||
limit: '8',
|
||||
countrycodes: 'in',
|
||||
viewbox: KANYAKUMARI_VIEWBOX_NOMINATIM,
|
||||
bounded: '1'
|
||||
});
|
||||
|
||||
const res = await fetchWithTimeout(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return [];
|
||||
const results = await res.json();
|
||||
if (!Array.isArray(results)) return [];
|
||||
|
||||
return results
|
||||
.filter((item) => isWithinKanyakumariDistrict(item.lat, item.lon))
|
||||
.map((item) => {
|
||||
if (!doorNo) return item;
|
||||
return {
|
||||
...item,
|
||||
display_name: `${doorNo}, ${item.display_name}`,
|
||||
address: {
|
||||
...item.address,
|
||||
house_number: doorNo || item.address?.house_number || ''
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Filter local Nagercoil / Kanyakumari knowledge base with door number and tokenized matching
|
||||
const matchLocalNagercoil = (query) => {
|
||||
if (!query || query.trim().length < 2) return [];
|
||||
const raw = query.trim();
|
||||
const rawLower = raw.toLowerCase();
|
||||
const { doorNo, cleanQuery } = extractDoorPrefix(raw);
|
||||
const cleanLower = cleanQuery.toLowerCase();
|
||||
const tokens = cleanLower.split(/[\s,]+/).filter((t) => t.length >= 2);
|
||||
|
||||
return NAGERCOIL_LOCALITIES.filter((loc) => {
|
||||
const nameLower = loc.name.toLowerCase();
|
||||
const displayLower = loc.display_name.toLowerCase();
|
||||
const suburbLower = loc.suburb.toLowerCase();
|
||||
|
||||
// 1. Direct query inside locality name or display name (e.g. query='kuru')
|
||||
if (nameLower.includes(rawLower) || displayLower.includes(rawLower) || suburbLower.includes(rawLower)) {
|
||||
return true;
|
||||
}
|
||||
// 2. Query contains locality name (e.g. query='262, kurusady, nagercoil')
|
||||
if (rawLower.includes(nameLower) || cleanLower.includes(nameLower)) {
|
||||
return true;
|
||||
}
|
||||
// 3. Any token in query matches locality name
|
||||
if (tokens.some((t) => nameLower.includes(t) || suburbLower.includes(t))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}).map((loc, idx) => {
|
||||
const formatted = doorNo ? `${doorNo}, ${loc.display_name}` : loc.display_name;
|
||||
return {
|
||||
place_id: `nagercoil-local-${idx}-${doorNo ? `${doorNo}-` : ''}${loc.name}`,
|
||||
name: doorNo ? `${doorNo}, ${loc.name}` : loc.name,
|
||||
display_name: formatted,
|
||||
lat: loc.lat,
|
||||
lon: loc.lon,
|
||||
address: {
|
||||
house_number: doorNo || '',
|
||||
suburb: loc.suburb,
|
||||
city: loc.city,
|
||||
state: loc.state,
|
||||
postcode: loc.postcode,
|
||||
country: 'India'
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Standalone forward-geocode helper — replaces `Geocode.fromAddress(address)`
|
||||
// for call sites that resolve a plain text address without a predictions
|
||||
// dropdown (e.g. re-geocoding on submit). Returns a `toPlace`-shaped object,
|
||||
// or null if nothing matched.
|
||||
//
|
||||
// Unlike Google's geocoder, Nominatim only matches when every term in the
|
||||
// query belongs to the same indexed record — it has no "closest approximate
|
||||
// match" behaviour. A door number + unlisted micro-locality/street name (very
|
||||
// common for Indian addresses, e.g. "60, Keezha Raman Puthoor, ...") makes
|
||||
// the whole query match nothing, even though the city/postcode later in the
|
||||
// string resolves fine on its own. So on a miss, retry with the leading
|
||||
// (most specific, least likely to be indexed) comma-separated segments
|
||||
// dropped one at a time until something matches — worst case this lands on
|
||||
// just "<city> <postcode>", an approximate pin instead of no pin at all.
|
||||
export async function geocodeAddress(address, { bias } = {}) {
|
||||
if (!address) return null;
|
||||
const plusCodeResult = await plusCodeToResult(address, bias);
|
||||
if (plusCodeResult) return toPlace(plusCodeResult);
|
||||
if (plusCodeResult && isWithinKanyakumariDistrict(plusCodeResult.lat, plusCodeResult.lon)) {
|
||||
return toPlace(plusCodeResult);
|
||||
}
|
||||
|
||||
// 1. Check local Nagercoil dictionary first
|
||||
const localMatches = matchLocalNagercoil(address);
|
||||
if (localMatches.length > 0) {
|
||||
return toPlace(localMatches[0]);
|
||||
}
|
||||
|
||||
// 2. Query Photon with Kanyakumari district bbox
|
||||
const photonResults = await searchPhoton(address, bias);
|
||||
if (photonResults.length > 0) {
|
||||
return toPlace(photonResults[0]);
|
||||
}
|
||||
|
||||
// 3. Fallback to Nominatim multi-segment query
|
||||
const segments = address
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const queries = segments.length > 1 ? segments.map((_, i) => segments.slice(i).join(', ')) : [address];
|
||||
try {
|
||||
for (const query of queries) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await searchOnce(query, bias);
|
||||
if (result) return toPlace(result);
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('geocodeAddress error:', err);
|
||||
return null;
|
||||
|
||||
for (const query of queries) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const nomResults = await searchNominatim(query, bias);
|
||||
if (nomResults?.length && isWithinKanyakumariDistrict(nomResults[0].lat, nomResults[0].lon)) return toPlace(nomResults[0]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Address search-as-you-type — replaces Google Places Autocomplete
|
||||
// (usePlacesWidget / window.google.maps.places.Autocomplete /
|
||||
// AutocompleteService) across the console. Renders as an MUI Autocomplete
|
||||
// with freeSolo so the operator can still submit free text.
|
||||
//
|
||||
// Props:
|
||||
// value / onChange(text) — controlled free-text input value
|
||||
// onPlaceSelected(place) — fired when a suggestion is chosen; `place`
|
||||
// is shaped like a Google Places `place`
|
||||
// bias { lat, lng } — optional soft bias toward the operator's zone
|
||||
const AddressAutocomplete = ({
|
||||
id,
|
||||
label,
|
||||
placeholder = 'Search address',
|
||||
placeholder = 'Search address or locality (e.g. Kurusady, Vadasery)',
|
||||
value,
|
||||
onChange,
|
||||
onPlaceSelected,
|
||||
@@ -224,26 +711,74 @@ const AddressAutocomplete = ({
|
||||
|
||||
const fetchPredictions = useMemo(
|
||||
() =>
|
||||
debounce((query, bias1, callback) => {
|
||||
if (!query) {
|
||||
debounce(async (query, bias1, callback) => {
|
||||
if (!query || query.trim().length < 2) {
|
||||
callback([]);
|
||||
return;
|
||||
}
|
||||
const params = buildParams(query, bias1);
|
||||
const searchPromise = fetchWithTimeout(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } })
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.catch(() => []);
|
||||
const plusCodePromise = plusCodeToResult(query, bias1).catch(() => null);
|
||||
Promise.all([searchPromise, plusCodePromise]).then(([results, plusCodeResult]) => {
|
||||
callback(plusCodeResult ? [plusCodeResult, ...(results || [])] : results || []);
|
||||
|
||||
const trimmed = query.trim();
|
||||
|
||||
// 1. Instant local Nagercoil / Kanyakumari suggestions (0ms)
|
||||
const localResults = matchLocalNagercoil(trimmed);
|
||||
|
||||
// 2. Plus Code check
|
||||
const plusCodePromise = plusCodeToResult(trimmed, bias1).catch(() => null);
|
||||
|
||||
// 3. Photon fast autocomplete (bounded to Kanyakumari District)
|
||||
const photonPromise = searchPhoton(trimmed, bias1);
|
||||
|
||||
// 4. Nominatim bounded fallback
|
||||
const nominatimPromise = searchNominatim(trimmed, bias1);
|
||||
|
||||
const [plusCodeResult, photonResults, nomResults] = await Promise.all([
|
||||
plusCodePromise,
|
||||
photonPromise,
|
||||
nominatimPromise
|
||||
]);
|
||||
|
||||
const combined = [];
|
||||
if (plusCodeResult && isWithinKanyakumariDistrict(plusCodeResult.lat, plusCodeResult.lon)) {
|
||||
combined.push(plusCodeResult);
|
||||
}
|
||||
|
||||
// Prioritize local Nagercoil dictionary matches
|
||||
localResults.forEach((item) => {
|
||||
if (isWithinKanyakumariDistrict(item.lat, item.lon)) {
|
||||
combined.push(item);
|
||||
}
|
||||
});
|
||||
}, 500),
|
||||
|
||||
// Add Photon results (already verified to be inside district)
|
||||
(photonResults || []).forEach((item) => {
|
||||
if (!isWithinKanyakumariDistrict(item.lat, item.lon)) return;
|
||||
const isDuplicate = combined.some(
|
||||
(c) =>
|
||||
c.display_name?.toLowerCase() === item.display_name?.toLowerCase() ||
|
||||
(Math.abs(Number(c.lat) - Number(item.lat)) < 0.001 && Math.abs(Number(c.lon) - Number(item.lon)) < 0.001)
|
||||
);
|
||||
if (!isDuplicate) combined.push(item);
|
||||
});
|
||||
|
||||
// Add Nominatim results (already verified to be inside district)
|
||||
(nomResults || []).forEach((item) => {
|
||||
if (!isWithinKanyakumariDistrict(item.lat, item.lon)) return;
|
||||
const isDuplicate = combined.some(
|
||||
(c) =>
|
||||
c.display_name?.toLowerCase() === item.display_name?.toLowerCase() ||
|
||||
(Math.abs(Number(c.lat) - Number(item.lat)) < 0.001 && Math.abs(Number(c.lon) - Number(item.lon)) < 0.001)
|
||||
);
|
||||
if (!isDuplicate) combined.push(item);
|
||||
});
|
||||
|
||||
callback(combined.slice(0, 8));
|
||||
}, 250),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!inputValue) {
|
||||
if (!inputValue || inputValue.trim().length < 2) {
|
||||
setOptions([]);
|
||||
setLoading(false);
|
||||
return undefined;
|
||||
@@ -252,7 +787,7 @@ const AddressAutocomplete = ({
|
||||
fetchPredictions(inputValue, bias, (results) => {
|
||||
if (!active) return;
|
||||
setLoading(false);
|
||||
setOptions(results);
|
||||
setOptions(results || []);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
@@ -269,14 +804,14 @@ const AddressAutocomplete = ({
|
||||
sx={sx}
|
||||
options={options}
|
||||
filterOptions={(x) => x}
|
||||
getOptionLabel={(option) => (typeof option === 'string' ? option : option.display_name || '')}
|
||||
isOptionEqualToValue={(option, val) => option.place_id === val?.place_id}
|
||||
getOptionLabel={(option) => (typeof option === 'string' ? option : option.display_name || option.name || '')}
|
||||
isOptionEqualToValue={(option, val) => option.place_id === val?.place_id || option.display_name === val?.display_name}
|
||||
noOptionsText={
|
||||
loading
|
||||
? 'Searching…'
|
||||
: inputValue.trim().length >= 3
|
||||
? 'No match found — drop the business name/Plus Code, or try just City + Postcode'
|
||||
: 'Type an address to search'
|
||||
? 'Searching Nagercoil & Kanyakumari District…'
|
||||
: inputValue.trim().length >= 2
|
||||
? 'No exact match found in Nagercoil / Kanyakumari — you can continue typing the full address'
|
||||
: 'Type an address or locality in Nagercoil / Kanyakumari'
|
||||
}
|
||||
inputValue={inputValue}
|
||||
onInputChange={(event, newInputValue) => {
|
||||
@@ -285,7 +820,7 @@ const AddressAutocomplete = ({
|
||||
}}
|
||||
onChange={(event, selected) => {
|
||||
if (selected && typeof selected !== 'string') {
|
||||
setInputValue(selected.display_name || '');
|
||||
setInputValue(selected.display_name || selected.name || '');
|
||||
onPlaceSelected?.(toPlace(selected));
|
||||
}
|
||||
}}
|
||||
@@ -316,3 +851,4 @@ const AddressAutocomplete = ({
|
||||
};
|
||||
|
||||
export default AddressAutocomplete;
|
||||
|
||||
|
||||
@@ -65,6 +65,13 @@ const nearle = {
|
||||
url: '/nearle/orders',
|
||||
icon: AiOutlineDashboard
|
||||
},
|
||||
{
|
||||
id: 'weeklyIntake',
|
||||
title: <FormattedMessage id="WeeklyIntake" />,
|
||||
type: 'item',
|
||||
url: '/nearle/orders/weekly-intake',
|
||||
icon: TbListDetails
|
||||
},
|
||||
{
|
||||
id: 'deliveries',
|
||||
title: <FormattedMessage id="Deliveries" />,
|
||||
|
||||
374
src/pages/nearle/ai/OpsAgentDrawer.js
Normal file
374
src/pages/nearle/ai/OpsAgentDrawer.js
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Ops Agent drawer — the console surface for the analysis in opsAnalysis.js.
|
||||
*
|
||||
* Every number rendered here comes from a pure function in that module, and
|
||||
* every finding carries the order IDs it was derived from. That is the contract
|
||||
* the drawer keeps: nothing is shown that cannot be pointed back at rows.
|
||||
*
|
||||
* There is no model behind this yet. `buildAgentContext` produces the fact
|
||||
* sheet an LLM would be handed, and "Copy agent context" puts it on the
|
||||
* clipboard so the prompt side can be developed against real output before the
|
||||
* endpoint exists.
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Collapse,
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import {
|
||||
MdClose,
|
||||
MdContentCopy,
|
||||
MdErrorOutline,
|
||||
MdExpandLess,
|
||||
MdExpandMore,
|
||||
MdCheckCircleOutline,
|
||||
MdWarningAmber
|
||||
} from 'react-icons/md';
|
||||
|
||||
import { buildAgentContext, formatMinutes, scanDataQuality, summariseDay, zoneRollup } from './opsAnalysis';
|
||||
|
||||
// Shared with the rest of the operator pages — see reports/ordersDetails.js.
|
||||
const BRAND = '#662582';
|
||||
const DT = {
|
||||
textPrimary: '#0f172a',
|
||||
textSecondary: '#64748b',
|
||||
textMuted: '#94a3b8',
|
||||
borderSubtle: '#e2e8f0',
|
||||
surfaceAlt: '#f8fafc'
|
||||
};
|
||||
|
||||
const SEVERITY = {
|
||||
blocking: { label: 'Blocking', color: '#b3261e', bg: '#fdecea', Icon: MdErrorOutline },
|
||||
high: { label: 'High', color: '#9a5b00', bg: '#fdf1de', Icon: MdWarningAmber },
|
||||
medium: { label: 'Medium', color: '#475569', bg: '#f1f5f9', Icon: MdWarningAmber },
|
||||
low: { label: 'Low', color: '#475569', bg: '#f1f5f9', Icon: MdWarningAmber }
|
||||
};
|
||||
|
||||
const mono = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace';
|
||||
|
||||
// ============================================================================
|
||||
|
||||
const Tile = ({ label, value, hint, tone }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
flex: '1 1 128px',
|
||||
minWidth: 128,
|
||||
px: 1.5,
|
||||
py: 1.125,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: DT.borderSubtle,
|
||||
bgcolor: '#fff'
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: 10, letterSpacing: 0.6, textTransform: 'uppercase', color: DT.textMuted, fontWeight: 700 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: 24, fontWeight: 700, lineHeight: 1.2, color: tone || DT.textPrimary, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
{hint && <Typography sx={{ fontSize: 11, color: DT.textSecondary }}>{hint}</Typography>}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
Tile.propTypes = { label: PropTypes.string, value: PropTypes.node, hint: PropTypes.string, tone: PropTypes.string };
|
||||
|
||||
// ============================================================================
|
||||
|
||||
const Finding = ({ finding, expanded, onToggle, onHighlight }) => {
|
||||
const meta = SEVERITY[finding.severity] || SEVERITY.medium;
|
||||
const { Icon } = meta;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: DT.borderSubtle,
|
||||
borderLeft: `3px solid ${meta.color}`,
|
||||
overflow: 'hidden',
|
||||
bgcolor: '#fff'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
alignItems: 'flex-start',
|
||||
'&:hover': { bgcolor: DT.surfaceAlt },
|
||||
'&:focus-visible': { outline: `2px solid ${BRAND}`, outlineOffset: -2 }
|
||||
}}
|
||||
>
|
||||
<Icon size={17} color={meta.color} style={{ flex: 'none', marginTop: 2 }} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" sx={{ mb: 0.25 }}>
|
||||
<Chip
|
||||
label={meta.label}
|
||||
size="small"
|
||||
sx={{ height: 18, fontSize: 10, fontWeight: 700, bgcolor: meta.bg, color: meta.color, '& .MuiChip-label': { px: 0.75 } }}
|
||||
/>
|
||||
{finding.orderIds.length > 0 && (
|
||||
<Typography sx={{ fontSize: 11, color: DT.textMuted }}>{finding.orderIds.length} orders</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography sx={{ fontSize: 13.5, fontWeight: 600, color: DT.textPrimary, lineHeight: 1.4 }}>{finding.title}</Typography>
|
||||
</Box>
|
||||
{expanded ? <MdExpandLess size={18} color={DT.textMuted} /> : <MdExpandMore size={18} color={DT.textMuted} />}
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded} unmountOnExit>
|
||||
<Box sx={{ px: 1.5, pb: 1.5, pt: 0 }}>
|
||||
<Typography sx={{ fontSize: 12.5, color: DT.textSecondary, lineHeight: 1.6, mb: 1 }}>{finding.detail}</Typography>
|
||||
|
||||
{finding.evidence.length > 0 && (
|
||||
<Box sx={{ p: 1, borderRadius: 1.5, bgcolor: DT.surfaceAlt, border: '1px solid', borderColor: DT.borderSubtle, mb: 1 }}>
|
||||
{finding.evidence.map((line) => (
|
||||
<Typography key={line} sx={{ fontFamily: mono, fontSize: 11, color: DT.textSecondary, whiteSpace: 'pre-wrap' }}>
|
||||
{line}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography sx={{ fontSize: 12.5, color: DT.textPrimary, lineHeight: 1.6 }}>
|
||||
<Box component="span" sx={{ fontSize: 10, fontWeight: 800, letterSpacing: 0.6, textTransform: 'uppercase', color: BRAND, mr: 0.75 }}>
|
||||
Fix
|
||||
</Box>
|
||||
{finding.fix}
|
||||
</Typography>
|
||||
|
||||
{finding.orderIds.length > 0 && onHighlight && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => onHighlight(finding.orderIds)}
|
||||
sx={{ mt: 1, textTransform: 'none', fontSize: 12, fontWeight: 700, color: BRAND, px: 0.5 }}
|
||||
>
|
||||
Show these {finding.orderIds.length} orders
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
Finding.propTypes = {
|
||||
finding: PropTypes.object.isRequired,
|
||||
expanded: PropTypes.bool,
|
||||
onToggle: PropTypes.func.isRequired,
|
||||
onHighlight: PropTypes.func
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
const OpsAgentDrawer = ({ open, onClose, rows, meta, onHighlight, totalAvailable }) => {
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Only recompute when the drawer is actually open — the scan walks every row
|
||||
// several times and the reports page re-renders on every filter change.
|
||||
const analysis = useMemo(() => {
|
||||
if (!open) return null;
|
||||
const source = Array.isArray(rows) ? rows : [];
|
||||
return { summary: summariseDay(source), findings: scanDataQuality(source), zones: zoneRollup(source) };
|
||||
}, [open, rows]);
|
||||
|
||||
const copyContext = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(buildAgentContext(rows || [], meta), null, 2));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
|
||||
const summary = analysis && analysis.summary;
|
||||
const blocking = analysis ? analysis.findings.filter((f) => f.severity === 'blocking').length : 0;
|
||||
|
||||
// The table pages in rows as the operator scrolls, so the drawer routinely
|
||||
// sees fewer orders than the filter matches. Saying "20 orders" beside a page
|
||||
// reading "62" would read as a bug in the analysis; say which it is.
|
||||
const partial = Boolean(summary && totalAvailable && totalAvailable > summary.orderCount);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
PaperProps={{ sx: { width: { xs: '100%', sm: 460 }, bgcolor: DT.surfaceAlt } }}
|
||||
>
|
||||
<Stack sx={{ height: '100%' }}>
|
||||
{/* Header */}
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1}
|
||||
sx={{ px: 2, py: 1.5, bgcolor: '#fff', borderBottom: '1px solid', borderColor: DT.borderSubtle }}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: 14.5, fontWeight: 800, color: DT.textPrimary, lineHeight: 1.3 }}>Ops Agent</Typography>
|
||||
<Typography sx={{ fontSize: 11.5, color: DT.textSecondary }}>
|
||||
{summary
|
||||
? `${summary.orderCount}${partial ? ` of ${totalAvailable}` : ''} order${summary.orderCount === 1 ? '' : 's'} · ` +
|
||||
`${summary.riders.length} rider${summary.riders.length === 1 ? '' : 's'}`
|
||||
: '—'}
|
||||
{meta && meta.range ? ` · ${meta.range}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title={copied ? 'Copied' : 'Copy the fact sheet an agent would be given'} arrow>
|
||||
<IconButton size="small" onClick={copyContext} aria-label="Copy agent context">
|
||||
{copied ? <MdCheckCircleOutline size={18} color="#2c6b45" /> : <MdContentCopy size={17} color={DT.textSecondary} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton size="small" onClick={onClose} aria-label="Close">
|
||||
<MdClose size={18} color={DT.textSecondary} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 2 }}>
|
||||
{!summary || summary.orderCount === 0 ? (
|
||||
<Typography sx={{ fontSize: 13, color: DT.textSecondary }}>
|
||||
No orders in the current filter. Widen the date range or clear the status filter, then reopen this panel.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{/* Verdict */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: '#fff',
|
||||
border: '1px solid',
|
||||
borderColor: DT.borderSubtle,
|
||||
borderLeft: `3px solid ${blocking ? SEVERITY.blocking.color : '#2c6b45'}`
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: 13.5, fontWeight: 700, color: DT.textPrimary, lineHeight: 1.45 }}>
|
||||
{blocking
|
||||
? `${summary.trustworthyDeliveryTimes} of ${summary.deliveryStampCount} delivery times can be trusted.`
|
||||
: 'Timestamps on this range look sound.'}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: 12.5, color: DT.textSecondary, lineHeight: 1.55, mt: 0.5 }}>
|
||||
{blocking
|
||||
? 'Until the findings below are resolved, treat any lateness, ETA or productivity number from this range as unmeasured.'
|
||||
: 'No blocking data-quality problems were detected in this range.'}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Tiles */}
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
<Tile
|
||||
label="Trusted times"
|
||||
value={`${summary.trustworthyDeliveryTimes}/${summary.deliveryStampCount}`}
|
||||
hint="delivery stamps"
|
||||
tone={summary.trustworthyDeliveryTimes < summary.deliveryStampCount ? SEVERITY.blocking.color : '#2c6b45'}
|
||||
/>
|
||||
<Tile
|
||||
label="Lifecycle capture"
|
||||
value={`${Math.round(summary.lifecycleRatio * 100)}%`}
|
||||
hint={`${summary.lifecycleCaptured} of ${summary.lifecycleExpected} stamps`}
|
||||
tone={summary.lifecycleRatio < 0.9 ? SEVERITY.high.color : '#2c6b45'}
|
||||
/>
|
||||
<Tile
|
||||
label="Slot → assign"
|
||||
value={summary.medianAssignDelayMin === null ? '—' : `${summary.medianAssignDelayMin > 0 ? '+' : ''}${summary.medianAssignDelayMin}m`}
|
||||
hint={summary.skew.suspected ? 'median, skew-corrected' : 'median'}
|
||||
/>
|
||||
<Tile label="Settled" value={`₹${summary.totalCharge}`} hint={`${summary.totalKm} km`} />
|
||||
</Stack>
|
||||
|
||||
{/* Findings */}
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: 10.5, fontWeight: 800, letterSpacing: 0.8, textTransform: 'uppercase', color: DT.textMuted, mb: 1 }}>
|
||||
{analysis.findings.length ? `${analysis.findings.length} findings` : 'Findings'}
|
||||
</Typography>
|
||||
{analysis.findings.length === 0 ? (
|
||||
<Typography sx={{ fontSize: 13, color: DT.textSecondary }}>Nothing flagged in this range.</Typography>
|
||||
) : (
|
||||
<Stack spacing={1}>
|
||||
{analysis.findings.map((f) => (
|
||||
<Finding
|
||||
key={f.id}
|
||||
finding={f}
|
||||
expanded={expandedId === f.id}
|
||||
onToggle={() => setExpandedId(expandedId === f.id ? null : f.id)}
|
||||
onHighlight={onHighlight}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Zones */}
|
||||
{analysis.zones.length > 0 && (
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: 10.5, fontWeight: 800, letterSpacing: 0.8, textTransform: 'uppercase', color: DT.textMuted, mb: 1 }}>
|
||||
By zone
|
||||
</Typography>
|
||||
<Paper elevation={0} sx={{ borderRadius: 2, border: '1px solid', borderColor: DT.borderSubtle, bgcolor: '#fff', overflow: 'hidden' }}>
|
||||
{analysis.zones.map((z, i) => (
|
||||
<Box key={z.zone}>
|
||||
{i > 0 && <Divider />}
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ px: 1.5, py: 1 }}>
|
||||
<Typography sx={{ flex: 1, fontSize: 12.5, fontWeight: 600, color: DT.textPrimary }}>{z.zone}</Typography>
|
||||
<Typography sx={{ fontFamily: mono, fontSize: 11.5, color: DT.textSecondary }}>
|
||||
{z.orders} · {z.km} km · ₹{z.charge}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography sx={{ fontSize: 11, color: DT.textMuted, lineHeight: 1.55 }}>
|
||||
Computed in the browser from the {summary.orderCount} rows currently loaded
|
||||
{partial ? ` of ${totalAvailable} matching this filter — scroll the table to load the rest` : ''}
|
||||
{summary.skew.suspected ? `, slots corrected by ${formatMinutes(summary.skew.suggestedShiftMin)}` : ''}. No model was
|
||||
called.
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
OpsAgentDrawer.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
rows: PropTypes.array,
|
||||
meta: PropTypes.object,
|
||||
onHighlight: PropTypes.func,
|
||||
totalAvailable: PropTypes.number
|
||||
};
|
||||
|
||||
export default OpsAgentDrawer;
|
||||
147
src/pages/nearle/ai/OpsAgentDrawer.test.js
Normal file
147
src/pages/nearle/ai/OpsAgentDrawer.test.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Smoke test for the Ops Agent drawer.
|
||||
*
|
||||
* The rest of this folder tests pure logic, which is the repo's preference —
|
||||
* but "does the component actually render without throwing" is exactly the gap
|
||||
* a build cannot close, and this drawer is the only place the analysis is ever
|
||||
* seen. It needs no react-query, router or notistack provider, so it can be
|
||||
* mounted directly; there is no @testing-library in this project, so it mounts
|
||||
* through react-dom/client and reads the DOM.
|
||||
*
|
||||
* MUI renders Drawer content into a portal on document.body, so assertions read
|
||||
* body text rather than the container.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import OpsAgentDrawer from './OpsAgentDrawer';
|
||||
|
||||
global.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const order = (orderid, deliverydate, assigntime, deliverytime, extra = {}) => ({
|
||||
orderid,
|
||||
tenantname: 'Bawa medical Nagercoil',
|
||||
rider: 'Sivakumar Subramani',
|
||||
deliverydate: `2026-09-02T${deliverydate}:00+05:30`,
|
||||
assigntime: `2026-09-02 ${assigntime}`,
|
||||
starttime: '',
|
||||
arrivaltime: '',
|
||||
pickuptime: '',
|
||||
deliverytime: `2026-09-02 ${deliverytime}`,
|
||||
deliverysuburb: 'King Nagar',
|
||||
...extra
|
||||
});
|
||||
|
||||
// The real 2 Sep day, trimmed to what the drawer renders.
|
||||
const DAY = [
|
||||
order('908-20246318', '14:00', '19:20:42', '22:59:24', { kms: '3', deliverycharge: '60', deliverylocation: 'Parvathipuram' }),
|
||||
order('908-20246317', '11:30', '19:20:42', '22:59:33', { kms: '1', deliverycharge: '60', deliverylocation: 'Puthukudierupu' }),
|
||||
order('908-20246316', '09:30', '14:36:45', '22:59:42', { kms: '3', deliverycharge: '60', deliverylocation: 'CBH ROAD' }),
|
||||
order('908-20246315', '07:30', '13:26:51', '22:59:51', { kms: '4', deliverycharge: '60', deliverylocation: 'NGL' }),
|
||||
order('908-20246313', '07:30', '13:26:51', '22:59:59', { kms: '3', deliverycharge: '60', deliverylocation: 'Erulapappuram' }),
|
||||
order('908-20246314', '07:30', '13:26:51', '23:00:07', { kms: '2', deliverycharge: '60', deliverylocation: 'Ramavarmapuram' }),
|
||||
order('908-20246312', '07:30', '12:39:45', '23:00:18', {
|
||||
kms: '1',
|
||||
deliverycharge: '60',
|
||||
deliverylocation: 'Simon Nagar',
|
||||
deliverysuburb: 'Weavers Colony'
|
||||
}),
|
||||
order('908-20246308', '05:30', '11:43:03', '23:00:25', {
|
||||
kms: '1',
|
||||
deliverycharge: '60',
|
||||
deliverylocation: 'ராமவர்மபுரம்',
|
||||
deliverysuburb: 'Weavers Colony'
|
||||
}),
|
||||
order('908-20246309', '06:00', '11:43:03', '23:00:34', { kms: '6', deliverycharge: '84', deliverylocation: 'Erumbukadu (post' }),
|
||||
order('908-20246310', '06:00', '11:43:03', '23:00:42', { kms: '4', deliverycharge: '60', deliverylocation: 'Kurusady' }),
|
||||
order('908-20246311', '06:00', '11:43:03', '23:00:50', { kms: '5', deliverycharge: '72', deliverylocation: 'NGL' }),
|
||||
order('908-20246307', '04:30', '10:04:53', '10:50:56', {
|
||||
kms: '3',
|
||||
deliverycharge: '60',
|
||||
deliverylocation: 'Udhayamarthandam',
|
||||
starttime: '2026-09-02 10:50:40',
|
||||
arrivaltime: '2026-09-02 10:33:25',
|
||||
pickuptime: '2026-09-02 10:33:38'
|
||||
})
|
||||
];
|
||||
|
||||
let container;
|
||||
let root;
|
||||
|
||||
const mount = (props) => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(<OpsAgentDrawer open onClose={() => {}} rows={DAY} {...props} />);
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root.unmount());
|
||||
if (container) container.remove();
|
||||
document.body.innerHTML = '';
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('OpsAgentDrawer', () => {
|
||||
it('renders the day without throwing and shows the verdict', () => {
|
||||
mount();
|
||||
const text = document.body.textContent;
|
||||
expect(text).toContain('Ops Agent');
|
||||
expect(text).toContain('12 orders');
|
||||
expect(text).toContain('1 rider');
|
||||
// 0 of 12 delivery stamps survive the bulk-close and event-order checks.
|
||||
expect(text).toContain('0/12');
|
||||
});
|
||||
|
||||
it('renders the headline tiles with the tested figures', () => {
|
||||
mount();
|
||||
const text = document.body.textContent;
|
||||
expect(text).toContain('45%'); // lifecycle capture, 27 of 60
|
||||
expect(text).toContain('27 of 60 stamps');
|
||||
expect(text).toContain('+13m'); // median slot to assignment, skew-corrected
|
||||
expect(text).toContain('₹756');
|
||||
expect(text).toContain('36 km');
|
||||
});
|
||||
|
||||
it('lists findings with the blocking ones present', () => {
|
||||
mount();
|
||||
const text = document.body.textContent;
|
||||
expect(text).toContain('Blocking');
|
||||
expect(text).toContain('Delivery timestamps were written in bulk');
|
||||
expect(text).toContain('Promised slots read exactly one IST offset early');
|
||||
});
|
||||
|
||||
it('rolls up both zones', () => {
|
||||
mount();
|
||||
const text = document.body.textContent;
|
||||
expect(text).toContain('King Nagar');
|
||||
expect(text).toContain('Weavers Colony');
|
||||
});
|
||||
|
||||
it('expands a finding to reveal its evidence and fix', () => {
|
||||
mount();
|
||||
const heading = [...document.querySelectorAll('[role="button"]')].find((el) =>
|
||||
el.textContent.includes('Delivery timestamps were written in bulk')
|
||||
);
|
||||
expect(heading).toBeTruthy();
|
||||
act(() => {
|
||||
heading.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
expect(document.body.textContent).toContain('Stamp delivery server-side from the rider action');
|
||||
});
|
||||
|
||||
it('shows an empty state rather than fabricating numbers when there are no rows', () => {
|
||||
mount({ rows: [] });
|
||||
expect(document.body.textContent).toContain('No orders in the current filter');
|
||||
});
|
||||
|
||||
it('says plainly that no model was called', () => {
|
||||
mount();
|
||||
expect(document.body.textContent).toContain('No model was called');
|
||||
});
|
||||
});
|
||||
255
src/pages/nearle/ai/intakeParser.js
Normal file
255
src/pages/nearle/ai/intakeParser.js
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Weekly intake parser — phase 1.
|
||||
*
|
||||
* Reads a sheet against the INTAKE_COLUMNS contract and classifies every row as
|
||||
* ok / needs-review / rejected, with the reason attached. It writes nothing and
|
||||
* calls nothing: given rows, it returns a verdict, which is what makes it
|
||||
* testable and what makes a dry run meaningful.
|
||||
*
|
||||
* Two decisions worth knowing:
|
||||
*
|
||||
* 1. **Unknown columns are reported, never ignored.** This codebase has already
|
||||
* been bitten by silent field drift — the API sends `ridername` and
|
||||
* `deliverycharges` while the CSV export writes `rider` and `deliverycharge`,
|
||||
* and reading the wrong one fails as a blank or a zero rather than an error.
|
||||
* A column we do not recognise is surfaced so someone decides about it.
|
||||
*
|
||||
* 2. **Rejected vs needs-review is about who can fix it.** Rejected means the
|
||||
* row cannot become an order at all (no phone, no address, outside the
|
||||
* service area). Needs-review means it could, but a person should look first.
|
||||
* Nothing is silently dropped.
|
||||
*/
|
||||
|
||||
import { INTAKE_COLUMNS, REQUIRED_KEYS, headerToKey, normaliseHeader } from './intakeTemplate';
|
||||
import { hasNonLatinScript } from './opsAnalysis';
|
||||
|
||||
export const STATUS = { OK: 'ok', REVIEW: 'needs-review', REJECTED: 'rejected' };
|
||||
|
||||
const COLUMN_BY_KEY = INTAKE_COLUMNS.reduce((acc, c) => {
|
||||
acc[c.key] = c;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const str = (v) => (v === null || v === undefined ? '' : String(v).trim());
|
||||
|
||||
/**
|
||||
* Strip leading numbering operators paste in from numbered lists — "1. Dhivya",
|
||||
* "02 Selvi". Same rule as cleanReceiverName in orders/multipleOrders.js.
|
||||
*/
|
||||
export const cleanName = (name) => str(name).replace(/^[\d.\s]+/, '').trim();
|
||||
|
||||
/**
|
||||
* Normalise an Indian mobile number to 10 digits, or return null.
|
||||
*
|
||||
* Accepts +91/91 prefixes and a leading 0 because both turn up in operator
|
||||
* spreadsheets. Rejects anything that is not a real mobile — a landline or a
|
||||
* truncated number would resolve to the wrong customer or none at all, and
|
||||
* phone is the key customer resolution matches on.
|
||||
*/
|
||||
export const normalisePhone = (value) => {
|
||||
const digits = str(value).replace(/\D/g, '');
|
||||
let local = digits;
|
||||
if (local.length === 12 && local.startsWith('91')) local = local.slice(2);
|
||||
else if (local.length === 11 && local.startsWith('0')) local = local.slice(1);
|
||||
if (local.length !== 10) return null;
|
||||
if (!/^[6-9]/.test(local)) return null;
|
||||
return local;
|
||||
};
|
||||
|
||||
const toNumber = (value, fallback) => {
|
||||
const s = str(value);
|
||||
if (s === '') return fallback;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
/** Great-circle distance in km. Inline so this module stays dependency-free. */
|
||||
export const haversineKm = (a, b) => {
|
||||
const R = 6371;
|
||||
const rad = (d) => (d * Math.PI) / 180;
|
||||
const dLat = rad(b.lat - a.lat);
|
||||
const dLng = rad(b.lng - a.lng);
|
||||
const s = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(s));
|
||||
};
|
||||
|
||||
/** A row is blank when every required column is empty — a trailing sheet row. */
|
||||
const isBlankRow = (mapped) => REQUIRED_KEYS.every((k) => str(mapped[k]) === '');
|
||||
|
||||
/**
|
||||
* Natural key for within-file duplicate detection. Phone plus a loosened
|
||||
* address: the same customer typed twice rarely matches character for
|
||||
* character, but almost always matches on digits and squashed letters.
|
||||
*/
|
||||
export const rowFingerprint = (normalised) =>
|
||||
`${normalised.receiverphone || ''}|${str(normalised.receiveraddress).toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
|
||||
/**
|
||||
* @param {object[]} sheetRows Rows as XLSX.utils.sheet_to_json / Papa.parse produce them.
|
||||
* @param {object} options
|
||||
* @param {{lat:number,lng:number}} [options.serviceCentre] Tenant hub, for the radius check.
|
||||
* @param {number} [options.serviceRadiusKm=25]
|
||||
* @param {Set<string>} [options.knownRunKeys] Fingerprints already committed in a previous run.
|
||||
*/
|
||||
export const parseIntakeFile = (sheetRows, options = {}) => {
|
||||
const { serviceCentre = null, serviceRadiusKm = 25, knownRunKeys = null } = options;
|
||||
const source = Array.isArray(sheetRows) ? sheetRows : [];
|
||||
|
||||
// With no rows there are no headers to inspect, so we cannot honestly say a
|
||||
// column is missing — the file is simply empty, and that is the useful thing
|
||||
// to report. Claiming four missing columns would send someone editing headers
|
||||
// that may well be correct.
|
||||
if (!source.length) {
|
||||
return { missingColumns: [], unknownColumns: [], rows: [], summary: { total: 0, ok: 0, needsReview: 0, rejected: 0 } };
|
||||
}
|
||||
|
||||
const headers = Object.keys(source[0]);
|
||||
const headerKeys = {};
|
||||
const unknownColumns = [];
|
||||
headers.forEach((h) => {
|
||||
const key = headerToKey(h);
|
||||
if (key) headerKeys[h] = key;
|
||||
else if (normaliseHeader(h) !== '') unknownColumns.push(h);
|
||||
});
|
||||
|
||||
const presentKeys = new Set(Object.values(headerKeys));
|
||||
const missingColumns = REQUIRED_KEYS.filter((k) => !presentKeys.has(k)).map((k) => COLUMN_BY_KEY[k].label);
|
||||
|
||||
const seen = new Map();
|
||||
const rows = [];
|
||||
|
||||
source.forEach((raw, i) => {
|
||||
const mapped = {};
|
||||
Object.keys(raw).forEach((h) => {
|
||||
const key = headerKeys[h];
|
||||
if (key) mapped[key] = raw[h];
|
||||
});
|
||||
|
||||
if (isBlankRow(mapped)) return;
|
||||
|
||||
const reasons = [];
|
||||
const normalised = { notes: str(mapped.notes) };
|
||||
|
||||
normalised.receivername = cleanName(mapped.receivername);
|
||||
if (!normalised.receivername) reasons.push({ level: 'reject', field: 'receivername', message: 'Receiver name is empty.' });
|
||||
|
||||
normalised.receiverphone = normalisePhone(mapped.receiverphone);
|
||||
if (!normalised.receiverphone) {
|
||||
reasons.push({
|
||||
level: 'reject',
|
||||
field: 'receiverphone',
|
||||
message: str(mapped.receiverphone)
|
||||
? `"${str(mapped.receiverphone)}" is not a 10-digit Indian mobile.`
|
||||
: 'Receiver phone is empty.'
|
||||
});
|
||||
}
|
||||
|
||||
normalised.receiveraddress = str(mapped.receiveraddress);
|
||||
if (!normalised.receiveraddress) reasons.push({ level: 'reject', field: 'receiveraddress', message: 'Receiver address is empty.' });
|
||||
|
||||
normalised.itemdescription = str(mapped.itemdescription);
|
||||
if (!normalised.itemdescription) reasons.push({ level: 'reject', field: 'itemdescription', message: 'Item description is empty.' });
|
||||
|
||||
const qty = toNumber(mapped.quantity, 1);
|
||||
if (qty === null || !Number.isInteger(qty) || qty < 1) {
|
||||
reasons.push({ level: 'reject', field: 'quantity', message: `Quantity must be a whole number of 1 or more, got "${str(mapped.quantity)}".` });
|
||||
normalised.quantity = null;
|
||||
} else {
|
||||
normalised.quantity = qty;
|
||||
}
|
||||
|
||||
const cash = toNumber(mapped.collectcash, 0);
|
||||
if (cash === null || cash < 0) {
|
||||
reasons.push({ level: 'reject', field: 'collectcash', message: `Collect Cash must be zero or more, got "${str(mapped.collectcash)}".` });
|
||||
normalised.collectcash = null;
|
||||
} else {
|
||||
normalised.collectcash = cash;
|
||||
}
|
||||
|
||||
// Coordinates are optional, but a present-and-wrong pair is worse than none.
|
||||
const lat = toNumber(mapped.receiverlat, null);
|
||||
const lng = toNumber(mapped.receiverlong, null);
|
||||
const hasCoords = lat !== null && lng !== null;
|
||||
if ((lat === null) !== (lng === null)) {
|
||||
reasons.push({ level: 'review', field: 'receiverlat', message: 'Only one of latitude/longitude is present; both are needed to route.' });
|
||||
}
|
||||
if (hasCoords && (Math.abs(lat) > 90 || Math.abs(lng) > 180)) {
|
||||
reasons.push({ level: 'reject', field: 'receiverlat', message: `Coordinates ${lat}, ${lng} are not a valid point on earth.` });
|
||||
} else if (hasCoords && serviceCentre) {
|
||||
const km = haversineKm(serviceCentre, { lat, lng });
|
||||
if (km > serviceRadiusKm) {
|
||||
reasons.push({
|
||||
level: 'reject',
|
||||
field: 'receiverlat',
|
||||
message: `Drop is ${km.toFixed(1)} km from the hub, beyond the ${serviceRadiusKm} km service radius.`
|
||||
});
|
||||
}
|
||||
normalised.distanceKm = Number(km.toFixed(2));
|
||||
}
|
||||
normalised.receiverlat = hasCoords ? lat : null;
|
||||
normalised.receiverlong = hasCoords ? lng : null;
|
||||
if (!hasCoords) {
|
||||
reasons.push({ level: 'review', field: 'receiverlat', message: 'No coordinates — the address will need geocoding before routing.' });
|
||||
}
|
||||
|
||||
if (hasNonLatinScript(normalised.receiveraddress) || hasNonLatinScript(normalised.receivername)) {
|
||||
reasons.push({
|
||||
level: 'review',
|
||||
field: 'receiveraddress',
|
||||
message: 'Contains non-Latin script; the same place may already exist under a Latin spelling.'
|
||||
});
|
||||
}
|
||||
|
||||
// Duplicates: within this file, and against anything already committed.
|
||||
const fingerprint = rowFingerprint(normalised);
|
||||
const sheetRow = i + 2; // 1-based, plus the header row — what the operator sees in Excel
|
||||
if (normalised.receiverphone) {
|
||||
if (seen.has(fingerprint)) {
|
||||
// Cite the spreadsheet row, not the array index. The operator is looking
|
||||
// at Excel, and a message that disagrees with the Row column is worse
|
||||
// than no message.
|
||||
reasons.push({ level: 'review', field: 'receiverphone', message: `Same phone and address as row ${seen.get(fingerprint)}.` });
|
||||
} else {
|
||||
seen.set(fingerprint, sheetRow);
|
||||
}
|
||||
if (knownRunKeys && knownRunKeys.has(fingerprint)) {
|
||||
reasons.push({ level: 'reject', field: 'receiverphone', message: 'Already created in an earlier run — this row would be a duplicate order.' });
|
||||
}
|
||||
}
|
||||
|
||||
const rejected = reasons.some((r) => r.level === 'reject');
|
||||
rows.push({
|
||||
index: i,
|
||||
sheetRow,
|
||||
raw,
|
||||
normalised,
|
||||
fingerprint,
|
||||
reasons,
|
||||
status: rejected ? STATUS.REJECTED : reasons.length ? STATUS.REVIEW : STATUS.OK
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
missingColumns,
|
||||
unknownColumns,
|
||||
rows,
|
||||
summary: {
|
||||
total: rows.length,
|
||||
ok: rows.filter((r) => r.status === STATUS.OK).length,
|
||||
needsReview: rows.filter((r) => r.status === STATUS.REVIEW).length,
|
||||
rejected: rows.filter((r) => r.status === STATUS.REJECTED).length
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/** Only rows safe to carry into customer resolution. */
|
||||
export const committableRows = (result) => (result ? result.rows.filter((r) => r.status !== STATUS.REJECTED) : []);
|
||||
|
||||
/** One-line summary for a snackbar or a log. */
|
||||
export const describeResult = (result) => {
|
||||
if (!result) return 'No file parsed.';
|
||||
if (result.missingColumns.length) return `Missing required column(s): ${result.missingColumns.join(', ')}.`;
|
||||
const { total, ok, needsReview, rejected } = result.summary;
|
||||
if (!total) return 'The file has no data rows.';
|
||||
return `${total} rows — ${ok} ready, ${needsReview} need review, ${rejected} rejected.`;
|
||||
};
|
||||
278
src/pages/nearle/ai/intakeParser.test.js
Normal file
278
src/pages/nearle/ai/intakeParser.test.js
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Tests for the weekly intake parser — phase 1.
|
||||
*
|
||||
* Rows are built from real deliveries in the 2 Sep 2026 Bawa Medical export,
|
||||
* re-expressed in the intake template's columns. Real values matter here: the
|
||||
* awkward cases in this file (a trailing space in a name, a Tamil-script
|
||||
* locality, an address that is mostly a plus-code) are the ones that actually
|
||||
* occur in this tenant's data, not ones invented to be awkward.
|
||||
*/
|
||||
|
||||
import {
|
||||
parseIntakeFile,
|
||||
normalisePhone,
|
||||
cleanName,
|
||||
haversineKm,
|
||||
rowFingerprint,
|
||||
committableRows,
|
||||
describeResult,
|
||||
STATUS
|
||||
} from './intakeParser';
|
||||
import { INTAKE_COLUMNS, REQUIRED_KEYS, headerToKey, templateRows } from './intakeTemplate';
|
||||
|
||||
// Bawa Medical, King Nagar — the pickup point on every order in the export.
|
||||
const HUB = { lat: 8.1841265, lng: 77.4318002 };
|
||||
|
||||
const sheet = (over = {}) => ({
|
||||
'Receiver Name*': 'Dhivya',
|
||||
'Receiver Phone*': '7358856373',
|
||||
'Receiver Address*': 'Gopala Krishna Complex, Joshua St, Puthukudierupu, Nagercoil 629001',
|
||||
'Item Description*': 'Medicines',
|
||||
Quantity: 1,
|
||||
'Collect Cash': 0,
|
||||
'Receiver Latitude': 8.1877496,
|
||||
'Receiver Longitude': 77.4241515,
|
||||
Notes: '',
|
||||
...over
|
||||
});
|
||||
|
||||
const parse = (rows, opts) => parseIntakeFile(rows, { serviceCentre: HUB, ...opts });
|
||||
|
||||
// ==========================================================================
|
||||
// Template contract
|
||||
// ==========================================================================
|
||||
|
||||
describe('intake template', () => {
|
||||
it('requires exactly the four things a sheet can legitimately assert', () => {
|
||||
expect(REQUIRED_KEYS).toEqual(['receivername', 'receiverphone', 'receiveraddress', 'itemdescription']);
|
||||
});
|
||||
|
||||
it('does not ask the sheet for sender, date or charge', () => {
|
||||
const keys = INTAKE_COLUMNS.map((c) => c.key);
|
||||
expect(keys).not.toContain('sendername');
|
||||
expect(keys).not.toContain('deliverydate');
|
||||
expect(keys).not.toContain('deliverycharge');
|
||||
});
|
||||
|
||||
it('emits a header row plus a filled example row', () => {
|
||||
const [headers, example] = templateRows();
|
||||
expect(headers).toHaveLength(INTAKE_COLUMNS.length);
|
||||
expect(headers[0]).toBe('Receiver Name*');
|
||||
expect(example[0]).toBe('Dhivya');
|
||||
});
|
||||
|
||||
it('accepts its own labels, bare keys, and the report export names', () => {
|
||||
expect(headerToKey('Receiver Name*')).toBe('receivername');
|
||||
expect(headerToKey('receivername')).toBe('receivername');
|
||||
expect(headerToKey(' RECEIVER NAME* ')).toBe('receivername');
|
||||
expect(headerToKey('deliverycustomer')).toBe('receivername');
|
||||
expect(headerToKey('deliverycontactno')).toBe('receiverphone');
|
||||
expect(headerToKey('rider')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Field normalisation
|
||||
// ==========================================================================
|
||||
|
||||
describe('normalisePhone', () => {
|
||||
it('accepts the forms operators actually paste', () => {
|
||||
expect(normalisePhone('7358856373')).toBe('7358856373');
|
||||
expect(normalisePhone('+91 73588 56373')).toBe('7358856373');
|
||||
expect(normalisePhone('91-7358856373')).toBe('7358856373');
|
||||
expect(normalisePhone('07358856373')).toBe('7358856373');
|
||||
expect(normalisePhone(7358856373)).toBe('7358856373');
|
||||
});
|
||||
|
||||
it('rejects what would resolve to the wrong customer or none', () => {
|
||||
expect(normalisePhone('735885637')).toBeNull(); // truncated
|
||||
expect(normalisePhone('1234567890')).toBeNull(); // not a mobile prefix
|
||||
expect(normalisePhone('')).toBeNull();
|
||||
expect(normalisePhone(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanName', () => {
|
||||
it('strips list numbering and trailing space', () => {
|
||||
expect(cleanName('1. Dhivya')).toBe('Dhivya');
|
||||
expect(cleanName('02 Selvi ')).toBe('Selvi');
|
||||
// Real value from the export — trailing space included.
|
||||
expect(cleanName('HAMEEZ RAMEEZ ')).toBe('HAMEEZ RAMEEZ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('haversineKm', () => {
|
||||
it('measures a known drop against the hub', () => {
|
||||
// Bawa Medical → Puthukudierupu, recorded as 1 km in the export.
|
||||
expect(haversineKm(HUB, { lat: 8.1877496, lng: 77.4241515 })).toBeCloseTo(0.9, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Row classification
|
||||
// ==========================================================================
|
||||
|
||||
describe('parseIntakeFile — happy path', () => {
|
||||
it('accepts a complete row with no reasons attached', () => {
|
||||
const r = parse([sheet()]);
|
||||
expect(r.missingColumns).toEqual([]);
|
||||
expect(r.unknownColumns).toEqual([]);
|
||||
expect(r.summary).toEqual({ total: 1, ok: 1, needsReview: 0, rejected: 0 });
|
||||
expect(r.rows[0].reasons).toEqual([]);
|
||||
expect(r.rows[0].normalised).toMatchObject({
|
||||
receivername: 'Dhivya',
|
||||
receiverphone: '7358856373',
|
||||
itemdescription: 'Medicines',
|
||||
quantity: 1,
|
||||
collectcash: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults quantity to 1 and cash to 0 when the columns are blank', () => {
|
||||
const r = parse([sheet({ Quantity: '', 'Collect Cash': '' })]);
|
||||
expect(r.rows[0].status).toBe(STATUS.OK);
|
||||
expect(r.rows[0].normalised.quantity).toBe(1);
|
||||
expect(r.rows[0].normalised.collectcash).toBe(0);
|
||||
});
|
||||
|
||||
it('reports the Excel row number an operator would look for', () => {
|
||||
const r = parse([sheet(), sheet({ 'Receiver Phone*': '9443094352' })]);
|
||||
expect(r.rows.map((x) => x.sheetRow)).toEqual([2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseIntakeFile — rejections', () => {
|
||||
const rejectFor = (over) => {
|
||||
const r = parse([sheet(over)]);
|
||||
expect(r.rows[0].status).toBe(STATUS.REJECTED);
|
||||
return r.rows[0].reasons.filter((x) => x.level === 'reject');
|
||||
};
|
||||
|
||||
it('rejects each missing required field, naming it', () => {
|
||||
expect(rejectFor({ 'Receiver Name*': '' })[0].field).toBe('receivername');
|
||||
expect(rejectFor({ 'Receiver Address*': '' })[0].field).toBe('receiveraddress');
|
||||
expect(rejectFor({ 'Item Description*': '' })[0].field).toBe('itemdescription');
|
||||
});
|
||||
|
||||
it('rejects an unusable phone and quotes what was given', () => {
|
||||
const [reason] = rejectFor({ 'Receiver Phone*': '12345' });
|
||||
expect(reason.field).toBe('receiverphone');
|
||||
expect(reason.message).toContain('12345');
|
||||
});
|
||||
|
||||
it('rejects a drop outside the service radius', () => {
|
||||
// Kanyakumari, ~20 km south — outside a 10 km radius.
|
||||
const r = parse([sheet({ 'Receiver Latitude': 8.0883, 'Receiver Longitude': 77.5385 })], { serviceRadiusKm: 10 });
|
||||
expect(r.rows[0].status).toBe(STATUS.REJECTED);
|
||||
expect(r.rows[0].reasons.some((x) => x.message.includes('service radius'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects impossible coordinates', () => {
|
||||
const reasons = rejectFor({ 'Receiver Latitude': 800, 'Receiver Longitude': 77.4 });
|
||||
expect(reasons[0].message).toContain('not a valid point');
|
||||
});
|
||||
|
||||
it('rejects a fractional or zero quantity', () => {
|
||||
expect(rejectFor({ Quantity: 0 })[0].field).toBe('quantity');
|
||||
expect(rejectFor({ Quantity: 1.5 })[0].field).toBe('quantity');
|
||||
});
|
||||
|
||||
it('rejects negative cash', () => {
|
||||
expect(rejectFor({ 'Collect Cash': -50 })[0].field).toBe('collectcash');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseIntakeFile — needs review', () => {
|
||||
it('flags a row with no coordinates rather than dropping it', () => {
|
||||
const r = parse([sheet({ 'Receiver Latitude': '', 'Receiver Longitude': '' })]);
|
||||
expect(r.rows[0].status).toBe(STATUS.REVIEW);
|
||||
expect(r.rows[0].reasons.some((x) => x.message.includes('geocoding'))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags a half-populated coordinate pair', () => {
|
||||
const r = parse([sheet({ 'Receiver Longitude': '' })]);
|
||||
expect(r.rows[0].reasons.some((x) => x.message.includes('Only one of'))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags non-Latin script, because the same place exists in Latin too', () => {
|
||||
const r = parse([sheet({ 'Receiver Address*': 'ராமவர்மபுரம், Nagercoil' })]);
|
||||
expect(r.rows[0].status).toBe(STATUS.REVIEW);
|
||||
expect(r.rows[0].reasons.some((x) => x.message.includes('non-Latin'))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags a repeat of the same phone and address inside one file', () => {
|
||||
const r = parse([sheet(), sheet()]);
|
||||
expect(r.rows[0].status).toBe(STATUS.OK);
|
||||
expect(r.rows[1].status).toBe(STATUS.REVIEW);
|
||||
// Cites the spreadsheet row the operator sees, not the array index.
|
||||
expect(r.rows[1].reasons.some((x) => x.message.includes('row 2'))).toBe(true);
|
||||
expect(r.rows[1].reasons.some((x) => x.message.includes('row 1'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag the same customer at a genuinely different address', () => {
|
||||
const r = parse([sheet(), sheet({ 'Receiver Address*': 'Beach Rd, Erulapappuram, Nagercoil' })]);
|
||||
expect(r.rows.every((x) => x.status === STATUS.OK)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Idempotency and file-level checks
|
||||
// ==========================================================================
|
||||
|
||||
describe('parseIntakeFile — replay protection', () => {
|
||||
it('rejects a row already committed in an earlier run', () => {
|
||||
const first = parse([sheet()]);
|
||||
const known = new Set([first.rows[0].fingerprint]);
|
||||
const second = parse([sheet()], { knownRunKeys: known });
|
||||
expect(second.rows[0].status).toBe(STATUS.REJECTED);
|
||||
expect(second.rows[0].reasons.some((x) => x.message.includes('earlier run'))).toBe(true);
|
||||
});
|
||||
|
||||
it('builds a fingerprint that survives address punctuation and casing', () => {
|
||||
const a = rowFingerprint({ receiverphone: '7358856373', receiveraddress: 'Joshua St, Nagercoil' });
|
||||
const b = rowFingerprint({ receiverphone: '7358856373', receiveraddress: 'joshua st nagercoil' });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseIntakeFile — file-level problems', () => {
|
||||
it('names every missing required column', () => {
|
||||
const r = parse([{ 'Receiver Name*': 'Dhivya', Quantity: 1 }]);
|
||||
expect(r.missingColumns).toEqual(['Receiver Phone*', 'Receiver Address*', 'Item Description*']);
|
||||
});
|
||||
|
||||
it('surfaces unknown columns instead of silently dropping them', () => {
|
||||
// The lesson from ridername/deliverycharges: a column we do not recognise
|
||||
// must be reported, because reading the wrong field fails as a blank.
|
||||
const r = parse([sheet({ 'Delivery Charge': 60, Rider: 'Sivakumar Subramani' })]);
|
||||
expect(r.unknownColumns).toEqual(expect.arrayContaining(['Delivery Charge', 'Rider']));
|
||||
});
|
||||
|
||||
it('ignores trailing blank rows', () => {
|
||||
const r = parse([sheet(), { 'Receiver Name*': '', 'Receiver Phone*': '', 'Receiver Address*': '', 'Item Description*': '' }]);
|
||||
expect(r.summary.total).toBe(1);
|
||||
});
|
||||
|
||||
it('handles an empty sheet without throwing', () => {
|
||||
const r = parse([]);
|
||||
expect(r.summary.total).toBe(0);
|
||||
expect(describeResult(r)).toBe('The file has no data rows.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('committableRows and describeResult', () => {
|
||||
it('carries forward everything except rejections', () => {
|
||||
const r = parse([sheet(), sheet({ 'Receiver Phone*': 'bad' }), sheet({ 'Receiver Latitude': '', 'Receiver Longitude': '' })]);
|
||||
const out = committableRows(r);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out.every((x) => x.status !== STATUS.REJECTED)).toBe(true);
|
||||
});
|
||||
|
||||
it('leads with the blocking problem when a column is missing', () => {
|
||||
expect(describeResult(parse([{ 'Receiver Name*': 'Dhivya' }]))).toContain('Missing required column');
|
||||
});
|
||||
|
||||
it('otherwise counts the three outcomes', () => {
|
||||
expect(describeResult(parse([sheet(), sheet({ 'Receiver Phone*': 'bad' })]))).toBe('2 rows — 1 ready, 0 need review, 1 rejected.');
|
||||
});
|
||||
});
|
||||
129
src/pages/nearle/ai/intakeTemplate.js
Normal file
129
src/pages/nearle/ai/intakeTemplate.js
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The weekly order intake contract.
|
||||
*
|
||||
* One column list, and everything downstream derives from it: the downloadable
|
||||
* template, the parser's validation, and the error messages an operator reads.
|
||||
* Changing the intake format means changing INTAKE_COLUMNS and nothing else.
|
||||
*
|
||||
* What is deliberately NOT here matters as much as what is:
|
||||
*
|
||||
* • no sender columns — the pickup location is chosen per batch, and carries
|
||||
* applocationid / partnerid / locationid / moduleid,
|
||||
* none of which a spreadsheet should be asserting
|
||||
* • no delivery date — that comes from the pickup slot chosen for the run.
|
||||
* `deliverydate` from the API is also timezone-suspect
|
||||
* (see opsAnalysis.detectOffsetSkew), so inheriting a
|
||||
* date from a sheet would bake that defect in
|
||||
* • no charge — computed from tenant pricing and distance at commit
|
||||
* time. A sheet that can set its own price is a sheet
|
||||
* that can under-bill.
|
||||
*
|
||||
* Header matching uses the same normalisation as the existing bulk uploader in
|
||||
* orders/multipleOrders.js, so a file that works there still works here.
|
||||
*/
|
||||
|
||||
/** Lowercase, trim, strip all whitespace — matches multipleOrders.js. */
|
||||
export const normaliseHeader = (header) =>
|
||||
header === null || header === undefined ? '' : String(header).trim().toLowerCase().replace(/\s+/g, '');
|
||||
|
||||
export const INTAKE_COLUMNS = [
|
||||
{
|
||||
key: 'receivername',
|
||||
label: 'Receiver Name*',
|
||||
required: true,
|
||||
example: 'Dhivya',
|
||||
help: 'Person receiving the delivery.'
|
||||
},
|
||||
{
|
||||
key: 'receiverphone',
|
||||
label: 'Receiver Phone*',
|
||||
required: true,
|
||||
example: '7358856373',
|
||||
help: '10-digit Indian mobile. Used to match an existing customer.'
|
||||
},
|
||||
{
|
||||
key: 'receiveraddress',
|
||||
label: 'Receiver Address*',
|
||||
required: true,
|
||||
example: 'Gopala Krishna Complex, Joshua St, Puthukudierupu, Nagercoil 629001',
|
||||
help: 'Full delivery address.'
|
||||
},
|
||||
{
|
||||
key: 'itemdescription',
|
||||
label: 'Item Description*',
|
||||
required: true,
|
||||
example: 'Medicines',
|
||||
help: 'What is being delivered.'
|
||||
},
|
||||
{ key: 'quantity', label: 'Quantity', required: false, default: 1, example: '1', help: 'Whole number. Defaults to 1.' },
|
||||
{
|
||||
key: 'collectcash',
|
||||
label: 'Collect Cash',
|
||||
required: false,
|
||||
default: 0,
|
||||
example: '0',
|
||||
help: 'Amount to collect on delivery. 0 for pay-later.'
|
||||
},
|
||||
{ key: 'receiverlat', label: 'Receiver Latitude', required: false, example: '8.1877496', help: 'Optional. Improves routing.' },
|
||||
{ key: 'receiverlong', label: 'Receiver Longitude', required: false, example: '77.4241515', help: 'Optional. Improves routing.' },
|
||||
{ key: 'notes', label: 'Notes', required: false, example: '', help: 'Anything the rider should know.' }
|
||||
];
|
||||
|
||||
export const REQUIRED_KEYS = INTAKE_COLUMNS.filter((c) => c.required).map((c) => c.key);
|
||||
|
||||
/** Normalised header → column key, for mapping an uploaded sheet's headers. */
|
||||
export const HEADER_TO_KEY = INTAKE_COLUMNS.reduce((acc, c) => {
|
||||
acc[normaliseHeader(c.label)] = c.key;
|
||||
// Accept the bare key too, so a file exported from this system round-trips.
|
||||
acc[normaliseHeader(c.key)] = c.key;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* Aliases for headers people actually send, mapped onto our keys.
|
||||
*
|
||||
* The report export and the older bulk uploader each use different names for
|
||||
* the same value; accepting them costs nothing and saves an operator from
|
||||
* hand-editing a file. Anything not listed here is reported as an unknown
|
||||
* column rather than silently dropped.
|
||||
*/
|
||||
export const HEADER_ALIASES = {
|
||||
// from the Orders_Detail report export
|
||||
deliverycustomer: 'receivername',
|
||||
deliverycontactno: 'receiverphone',
|
||||
deliveryaddress: 'receiveraddress',
|
||||
deliverylat: 'receiverlat',
|
||||
deliverylong: 'receiverlong',
|
||||
ordernotes: 'notes',
|
||||
// from orders/multipleOrders.js
|
||||
'receivername*': 'receivername',
|
||||
receiverfulladdress: 'receiveraddress',
|
||||
receiverlatitude: 'receiverlat',
|
||||
receiverlongitude: 'receiverlong',
|
||||
'itemdescription*': 'itemdescription',
|
||||
collectcash: 'collectcash'
|
||||
};
|
||||
|
||||
/** Resolve any incoming header to a column key, or '' if unrecognised. */
|
||||
export const headerToKey = (header) => {
|
||||
const n = normaliseHeader(header);
|
||||
return HEADER_TO_KEY[n] || HEADER_ALIASES[n] || '';
|
||||
};
|
||||
|
||||
/** Header row plus one filled example row, for the downloadable template. */
|
||||
export const templateRows = () => [INTAKE_COLUMNS.map((c) => c.label), INTAKE_COLUMNS.map((c) => c.example)];
|
||||
|
||||
/**
|
||||
* Write the template to the operator's machine.
|
||||
*
|
||||
* Kept as the only impure function in this module and injected with XLSX so the
|
||||
* contract above stays testable without touching the DOM.
|
||||
*/
|
||||
export const downloadTemplate = async (filename = 'Weekly_Order_Intake_Template.xlsx') => {
|
||||
const XLSX = await import('xlsx');
|
||||
const sheet = XLSX.utils.aoa_to_sheet(templateRows());
|
||||
sheet['!cols'] = INTAKE_COLUMNS.map((c) => ({ wch: Math.max(c.label.length + 2, 16) }));
|
||||
const book = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(book, sheet, 'Orders');
|
||||
XLSX.writeFile(book, filename);
|
||||
};
|
||||
528
src/pages/nearle/ai/opsAnalysis.js
Normal file
528
src/pages/nearle/ai/opsAnalysis.js
Normal file
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* Ops analysis — the grounded layer under the ops agent.
|
||||
*
|
||||
* Every function here is pure: order rows in, findings out. No React, no
|
||||
* network, no localStorage. That is deliberate — an LLM answering questions
|
||||
* about a day's operations must be handed facts that were computed, not facts
|
||||
* it inferred from raw rows, and those facts have to be testable on their own.
|
||||
*
|
||||
* The rule this module exists to enforce: **never silently "correct" data.**
|
||||
* Where a field looks wrong (see detectOffsetSkew), we detect and report the
|
||||
* discrepancy and let the caller opt into a corrected view. A normaliser that
|
||||
* quietly shifted timestamps would double-correct the moment the backend is
|
||||
* fixed, and nobody would notice.
|
||||
*/
|
||||
|
||||
// Minutes in the IST offset. Used only as the *hypothesis* detectOffsetSkew
|
||||
// tests against — it is never applied unconditionally.
|
||||
export const IST_OFFSET_MIN = 330;
|
||||
|
||||
// A row with no orderid is the TOTAL row the CSV export appends (see
|
||||
// reports/ordersDetails.js). It carries column sums, not an order.
|
||||
export const isOrderRow = (row) => Boolean(row && String(row.orderid || '').trim());
|
||||
|
||||
/** Drop export artifacts (TOTAL row, blank rows) from a fetched/parsed set. */
|
||||
export const onlyOrders = (rows) => (Array.isArray(rows) ? rows.filter(isOrderRow) : []);
|
||||
|
||||
/**
|
||||
* Minutes-since-midnight of the *literal* clock in a timestamp string.
|
||||
*
|
||||
* Deliberately ignores any timezone offset. `deliverydate` arrives as
|
||||
* "2026-09-02T04:30:00+05:30" while `assigntime` arrives naive; comparing the
|
||||
* instants would bake in the very offset question we are trying to measure, so
|
||||
* we compare what an operator would read off the screen instead.
|
||||
*/
|
||||
export const wallClockMinutes = (value) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
const m = /(\d{1,2}):(\d{2})(?::(\d{2}))?/.exec(String(value));
|
||||
if (!m) return null;
|
||||
const h = Number(m[1]);
|
||||
const min = Number(m[2]);
|
||||
if (h > 23 || min > 59) return null;
|
||||
return h * 60 + min + Number(m[3] || 0) / 60;
|
||||
};
|
||||
|
||||
/** Seconds-since-midnight, for clustering where second-level gaps matter. */
|
||||
export const wallClockSeconds = (value) => {
|
||||
const mins = wallClockMinutes(value);
|
||||
return mins === null ? null : Math.round(mins * 60);
|
||||
};
|
||||
|
||||
export const formatMinutes = (mins) => {
|
||||
if (mins === null || mins === undefined || Number.isNaN(mins)) return '—';
|
||||
const total = Math.round(mins);
|
||||
const h = Math.floor(total / 60) % 24;
|
||||
const m = total % 60;
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const median = (nums) => {
|
||||
const xs = nums.filter((n) => typeof n === 'number' && !Number.isNaN(n)).sort((a, b) => a - b);
|
||||
if (!xs.length) return null;
|
||||
const mid = Math.floor(xs.length / 2);
|
||||
return xs.length % 2 ? xs[mid] : (xs[mid - 1] + xs[mid]) / 2;
|
||||
};
|
||||
|
||||
// Wrap a signed minute delta into (-720, 720] so a slot near midnight compared
|
||||
// against an assignment just after it reads as a small delta, not ~23 hours.
|
||||
const wrapDelta = (mins) => {
|
||||
let d = mins;
|
||||
while (d > 720) d -= 1440;
|
||||
while (d <= -720) d += 1440;
|
||||
return d;
|
||||
};
|
||||
|
||||
// The five lifecycle stamps, in the order they must occur.
|
||||
export const LIFECYCLE_STAGES = ['assigntime', 'starttime', 'arrivaltime', 'pickuptime', 'deliverytime'];
|
||||
|
||||
const STAGE_LABEL = {
|
||||
assigntime: 'assign',
|
||||
starttime: 'start',
|
||||
arrivaltime: 'arrival',
|
||||
pickuptime: 'pickup',
|
||||
deliverytime: 'delivery'
|
||||
};
|
||||
|
||||
const has = (v) => v !== null && v !== undefined && String(v).trim() !== '';
|
||||
|
||||
/**
|
||||
* The same value arrives under different names depending on where the rows came
|
||||
* from, and reading the wrong one fails silently as a zero or a blank rather
|
||||
* than an error. The orders-detail endpoint sends `ridername` and
|
||||
* `deliverycharges`; the CSV export re-maps them to `rider` and
|
||||
* `deliverycharge`. Both are in use, so read both.
|
||||
*/
|
||||
const FIELD_ALIASES = {
|
||||
rider: ['ridername', 'rider'],
|
||||
charge: ['deliverycharges', 'deliverycharge'],
|
||||
distance: ['kms', 'actualkms'],
|
||||
zone: ['deliverysuburb', 'locationsuburb'],
|
||||
locality: ['deliverylocation', 'deliverysuburb']
|
||||
};
|
||||
|
||||
export const readField = (row, key) => {
|
||||
const names = FIELD_ALIASES[key] || [key];
|
||||
for (let i = 0; i < names.length; i += 1) {
|
||||
if (has(row && row[names[i]])) return row[names[i]];
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Offset skew — is `deliverydate` labelled +05:30 but carrying a UTC clock?
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Compares each order's promised slot against when it was actually assigned.
|
||||
*
|
||||
* If the backend serialises a UTC wall clock with an IST offset label, every
|
||||
* slot reads exactly IST_OFFSET_MIN early, so the median (assign − slot) across
|
||||
* a day lands near +330 minutes with little spread. Real dispatch lateness does
|
||||
* not cluster that tightly on that specific number.
|
||||
*
|
||||
* Returns `suspected: true` only when the median is close to the offset AND the
|
||||
* spread is tight AND there are enough orders to mean anything — so a genuinely
|
||||
* chaotic day is not mistaken for a serialisation bug.
|
||||
*/
|
||||
export const detectOffsetSkew = (rows, { toleranceMin = 45, minOrders = 5 } = {}) => {
|
||||
const deltas = [];
|
||||
onlyOrders(rows).forEach((o) => {
|
||||
const slot = wallClockMinutes(o.deliverydate);
|
||||
const assign = wallClockMinutes(o.assigntime);
|
||||
if (slot === null || assign === null) return;
|
||||
deltas.push(wrapDelta(assign - slot));
|
||||
});
|
||||
|
||||
const med = median(deltas);
|
||||
if (med === null || deltas.length < minOrders) {
|
||||
return { suspected: false, sampleSize: deltas.length, medianDeltaMin: med, spreadMin: null, suggestedShiftMin: 0 };
|
||||
}
|
||||
|
||||
// Spread as median absolute deviation — robust to the one order that really
|
||||
// was assigned hours late.
|
||||
const spread = median(deltas.map((d) => Math.abs(d - med)));
|
||||
const suspected = Math.abs(med - IST_OFFSET_MIN) <= toleranceMin && spread <= toleranceMin;
|
||||
|
||||
return {
|
||||
suspected,
|
||||
sampleSize: deltas.length,
|
||||
medianDeltaMin: Math.round(med),
|
||||
spreadMin: Math.round(spread),
|
||||
suggestedShiftMin: suspected ? IST_OFFSET_MIN : 0
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The promised slot in minutes, optionally shifted by a detected skew.
|
||||
* `shiftMin` should come from detectOffsetSkew().suggestedShiftMin — pass 0 to
|
||||
* read the field exactly as the API sent it.
|
||||
*/
|
||||
export const slotMinutes = (order, shiftMin = 0) => {
|
||||
const raw = wallClockMinutes(order && order.deliverydate);
|
||||
return raw === null ? null : (raw + shiftMin + 1440) % 1440;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Bulk close — deliveries stamped in a burst rather than as they happened
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Finds runs of orders whose delivery stamps sit implausibly close together.
|
||||
*
|
||||
* A rider physically cannot complete drops seconds apart, so a tight run is an
|
||||
* end-of-day close-out. `descendingByOrderId` is the corroborating tell: a
|
||||
* human clearing a list works down it in one direction.
|
||||
*/
|
||||
export const detectBulkClose = (rows, { maxGapSeconds = 25, minClusterSize = 4 } = {}) => {
|
||||
const stamped = onlyOrders(rows)
|
||||
.map((o) => ({ orderid: String(o.orderid), seconds: wallClockSeconds(o.deliverytime), raw: o.deliverytime }))
|
||||
.filter((o) => o.seconds !== null)
|
||||
.sort((a, b) => a.seconds - b.seconds);
|
||||
|
||||
const clusters = [];
|
||||
let run = [];
|
||||
|
||||
const flush = () => {
|
||||
if (run.length >= minClusterSize) {
|
||||
const gaps = run.slice(1).map((o, i) => o.seconds - run[i].seconds);
|
||||
const ids = run.map((o) => o.orderid);
|
||||
const sortedDesc = ids.every((id, i) => i === 0 || ids[i - 1] >= id);
|
||||
const sortedAsc = ids.every((id, i) => i === 0 || ids[i - 1] <= id);
|
||||
clusters.push({
|
||||
count: run.length,
|
||||
orderIds: ids,
|
||||
firstStamp: run[0].raw,
|
||||
lastStamp: run[run.length - 1].raw,
|
||||
spanSeconds: run[run.length - 1].seconds - run[0].seconds,
|
||||
medianGapSeconds: median(gaps),
|
||||
monotonicByOrderId: sortedDesc || sortedAsc
|
||||
});
|
||||
}
|
||||
run = [];
|
||||
};
|
||||
|
||||
stamped.forEach((o, i) => {
|
||||
if (i === 0 || o.seconds - stamped[i - 1].seconds <= maxGapSeconds) {
|
||||
run.push(o);
|
||||
} else {
|
||||
flush();
|
||||
run = [o];
|
||||
}
|
||||
});
|
||||
flush();
|
||||
|
||||
return clusters;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Lifecycle capture and event ordering
|
||||
// ============================================================================
|
||||
|
||||
export const lifecycleCapture = (rows) => {
|
||||
const orders = onlyOrders(rows);
|
||||
const stages = {};
|
||||
LIFECYCLE_STAGES.forEach((s) => {
|
||||
stages[s] = orders.filter((o) => has(o[s])).length;
|
||||
});
|
||||
const captured = LIFECYCLE_STAGES.reduce((sum, s) => sum + stages[s], 0);
|
||||
const expected = orders.length * LIFECYCLE_STAGES.length;
|
||||
return { stages, captured, expected, ratio: expected ? captured / expected : 0, orderCount: orders.length };
|
||||
};
|
||||
|
||||
/** Lifecycle stamps that go backwards in time within a single order. */
|
||||
export const detectOutOfOrderEvents = (rows) => {
|
||||
const violations = [];
|
||||
onlyOrders(rows).forEach((o) => {
|
||||
const seen = LIFECYCLE_STAGES.map((s) => ({ stage: s, seconds: has(o[s]) ? wallClockSeconds(o[s]) : null })).filter(
|
||||
(s) => s.seconds !== null
|
||||
);
|
||||
for (let i = 1; i < seen.length; i += 1) {
|
||||
if (seen[i].seconds < seen[i - 1].seconds) {
|
||||
violations.push({
|
||||
orderid: String(o.orderid),
|
||||
earlier: STAGE_LABEL[seen[i].stage],
|
||||
later: STAGE_LABEL[seen[i - 1].stage],
|
||||
earlierValue: o[seen[i].stage],
|
||||
laterValue: o[seen[i - 1].stage]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return violations;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Locality hygiene
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* True when a string carries characters outside the Latin blocks. In this data
|
||||
* that means Tamil, where one locality is written both as ராமவர்மபுரம் and as
|
||||
* Ramavarmapuram, and the two never match as strings.
|
||||
*/
|
||||
export const hasNonLatinScript = (s) => /[^ -ɏ\s\d\p{P}]/u.test(String(s || ''));
|
||||
|
||||
const hasNonLatin = hasNonLatinScript;
|
||||
const unbalanced = (s) => {
|
||||
const open = (s.match(/[([{]/g) || []).length;
|
||||
const close = (s.match(/[)\]}]/g) || []).length;
|
||||
return open !== close;
|
||||
};
|
||||
|
||||
export const scanLocalities = (rows) => {
|
||||
const values = onlyOrders(rows)
|
||||
.map((o) => String(readField(o, 'locality')).trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const byLower = {};
|
||||
values.forEach((v) => {
|
||||
const k = v.toLowerCase();
|
||||
byLower[k] = byLower[k] || new Set();
|
||||
byLower[k].add(v);
|
||||
});
|
||||
|
||||
return {
|
||||
nonLatin: [...new Set(values.filter(hasNonLatin))],
|
||||
unbalanced: [...new Set(values.filter(unbalanced))],
|
||||
caseVariants: Object.values(byLower)
|
||||
.filter((set) => set.size > 1)
|
||||
.map((set) => [...set]),
|
||||
distinct: Object.keys(byLower).length,
|
||||
total: values.length
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Rollups
|
||||
// ============================================================================
|
||||
|
||||
const num = (v) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
export const zoneRollup = (rows) => {
|
||||
const map = {};
|
||||
onlyOrders(rows).forEach((o) => {
|
||||
const key = String(readField(o, 'zone') || 'Unassigned').trim() || 'Unassigned';
|
||||
map[key] = map[key] || { zone: key, orders: 0, km: 0, charge: 0 };
|
||||
map[key].orders += 1;
|
||||
map[key].km += num(readField(o, 'distance'));
|
||||
map[key].charge += num(readField(o, 'charge'));
|
||||
});
|
||||
return Object.values(map)
|
||||
.map((z) => ({ ...z, kmPerDrop: z.orders ? z.km / z.orders : 0, chargePerDrop: z.orders ? z.charge / z.orders : 0 }))
|
||||
.sort((a, b) => b.orders - a.orders);
|
||||
};
|
||||
|
||||
/**
|
||||
* Headline numbers for the drawer.
|
||||
*
|
||||
* `trustworthyDeliveryTimes` counts only orders whose delivery stamp is neither
|
||||
* part of a bulk-close cluster nor contradicted by its own event ordering —
|
||||
* which is the number an operator actually needs before reading any SLA.
|
||||
*/
|
||||
export const summariseDay = (rows) => {
|
||||
const orders = onlyOrders(rows);
|
||||
const skew = detectOffsetSkew(rows);
|
||||
const clusters = detectBulkClose(rows);
|
||||
const capture = lifecycleCapture(rows);
|
||||
const outOfOrder = detectOutOfOrderEvents(rows);
|
||||
|
||||
const suspect = new Set();
|
||||
clusters.forEach((c) => c.orderIds.forEach((id) => suspect.add(id)));
|
||||
outOfOrder.forEach((v) => suspect.add(v.orderid));
|
||||
|
||||
const withDelivery = orders.filter((o) => has(o.deliverytime));
|
||||
const assignDeltas = orders
|
||||
.map((o) => {
|
||||
const slot = slotMinutes(o, skew.suggestedShiftMin);
|
||||
const assign = wallClockMinutes(o.assigntime);
|
||||
return slot === null || assign === null ? null : wrapDelta(assign - slot);
|
||||
})
|
||||
.filter((d) => d !== null);
|
||||
|
||||
return {
|
||||
orderCount: orders.length,
|
||||
trustworthyDeliveryTimes: withDelivery.filter((o) => !suspect.has(String(o.orderid))).length,
|
||||
deliveryStampCount: withDelivery.length,
|
||||
lifecycleRatio: capture.ratio,
|
||||
lifecycleCaptured: capture.captured,
|
||||
lifecycleExpected: capture.expected,
|
||||
// Rounded: the underlying stamps carry seconds, but a delay reported to an
|
||||
// operator in fractional minutes is noise, not precision.
|
||||
medianAssignDelayMin: assignDeltas.length ? Math.round(median(assignDeltas)) : null,
|
||||
totalKm: orders.reduce((s, o) => s + num(readField(o, 'distance')), 0),
|
||||
totalCharge: orders.reduce((s, o) => s + num(readField(o, 'charge')), 0),
|
||||
riders: [...new Set(orders.map((o) => String(readField(o, 'rider')).trim()).filter(Boolean))],
|
||||
skew
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// The scan
|
||||
// ============================================================================
|
||||
|
||||
const SEVERITY_ORDER = { blocking: 0, high: 1, medium: 2, low: 3 };
|
||||
|
||||
/**
|
||||
* Runs every check and returns findings ordered by what they block.
|
||||
*
|
||||
* Each finding carries `orderIds` so the UI can highlight the rows a claim came
|
||||
* from. A finding with no evidence it can point at is not worth showing.
|
||||
*/
|
||||
export const scanDataQuality = (rows) => {
|
||||
const orders = onlyOrders(rows);
|
||||
const findings = [];
|
||||
if (!orders.length) return findings;
|
||||
|
||||
const rawCount = Array.isArray(rows) ? rows.length : 0;
|
||||
|
||||
// 1. Bulk-closed deliveries.
|
||||
detectBulkClose(rows).forEach((c, i) => {
|
||||
findings.push({
|
||||
id: `bulk-close-${i}`,
|
||||
severity: 'blocking',
|
||||
title: 'Delivery timestamps were written in bulk, not observed',
|
||||
detail:
|
||||
`${c.count} orders were stamped delivered inside ${c.spanSeconds} seconds` +
|
||||
(c.medianGapSeconds !== null ? `, a median of ${Math.round(c.medianGapSeconds)}s apart` : '') +
|
||||
(c.monotonicByOrderId ? ', running in order-ID sequence' : '') +
|
||||
'. That is a list being cleared, not deliveries happening. No SLA, ETA accuracy or rider productivity figure can be derived from this column.',
|
||||
evidence: [`${c.firstStamp} → ${c.lastStamp}`, `${c.count} orders, span ${c.spanSeconds}s`],
|
||||
orderIds: c.orderIds,
|
||||
fix: 'Stamp delivery server-side from the rider action, and rate-limit closes per rider per minute.'
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Timezone skew.
|
||||
const skew = detectOffsetSkew(rows);
|
||||
if (skew.suspected) {
|
||||
findings.push({
|
||||
id: 'offset-skew',
|
||||
severity: 'blocking',
|
||||
title: 'Promised slots read exactly one IST offset early',
|
||||
detail:
|
||||
`Across ${skew.sampleSize} orders the median gap from promised slot to assignment is ${skew.medianDeltaMin} minutes ` +
|
||||
`(spread ±${skew.spreadMin}), which is the +05:30 offset itself. The likeliest cause is a UTC clock serialised with an ` +
|
||||
'IST label, so every slot in the console reads five and a half hours early. Confirm against the API before trusting either reading.',
|
||||
evidence: [`median assign − slot = ${skew.medianDeltaMin} min`, `IST offset = ${IST_OFFSET_MIN} min`, `spread ±${skew.spreadMin} min`],
|
||||
orderIds: orders.map((o) => String(o.orderid)),
|
||||
fix: 'Serialise as true UTC with Z, or true IST with +05:30 — and normalise on ingest, before anything reads it.'
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Lifecycle capture.
|
||||
const capture = lifecycleCapture(rows);
|
||||
if (capture.ratio < 0.9) {
|
||||
const missing = LIFECYCLE_STAGES.filter((s) => capture.stages[s] < capture.orderCount);
|
||||
findings.push({
|
||||
id: 'lifecycle-gaps',
|
||||
severity: 'high',
|
||||
title: `The rider app records ${Math.round(capture.ratio * 100)}% of lifecycle events`,
|
||||
detail:
|
||||
`${capture.captured} of ${capture.expected} expected stamps are present. ` +
|
||||
`Weakest stages: ${missing.map((s) => STAGE_LABEL[s]).join(', ')}. Cycle-time analysis is not available from this feed at any sample size.`,
|
||||
evidence: LIFECYCLE_STAGES.map((s) => `${STAGE_LABEL[s].padEnd(9)} ${capture.stages[s]}/${capture.orderCount}`),
|
||||
orderIds: orders.filter((o) => LIFECYCLE_STAGES.some((s) => !has(o[s]))).map((o) => String(o.orderid)),
|
||||
fix: 'Instrument the intermediate transitions, then reject writes that arrive out of order.'
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Event ordering.
|
||||
const violations = detectOutOfOrderEvents(rows);
|
||||
if (violations.length) {
|
||||
findings.push({
|
||||
id: 'event-order',
|
||||
severity: 'high',
|
||||
title: 'Lifecycle stamps run backwards within an order',
|
||||
detail:
|
||||
`${violations.length} order(s) record a stage before the stage that must precede it. Even where stamps exist, they cannot be ` +
|
||||
'used to measure duration.',
|
||||
evidence: violations
|
||||
.slice(0, 4)
|
||||
.map((v) => `${v.orderid}: ${v.earlier} ${v.earlierValue} precedes ${v.later} ${v.laterValue}`),
|
||||
orderIds: violations.map((v) => v.orderid),
|
||||
fix: 'Add a monotonic-ordering check at write time so an impossible sequence is rejected, not stored.'
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Export artifacts.
|
||||
if (rawCount > orders.length) {
|
||||
findings.push({
|
||||
id: 'export-artifact',
|
||||
severity: 'medium',
|
||||
title: 'The row set contains non-order rows',
|
||||
detail:
|
||||
`${rawCount - orders.length} row(s) carry no orderid — the TOTAL row the CSV export appends. Any pipeline reading the file as ` +
|
||||
'records gains phantom orders, with the column sums as their values.',
|
||||
evidence: [`${rawCount} rows in, ${orders.length} actual orders`],
|
||||
orderIds: [],
|
||||
fix: 'Move totals to a footer sheet, or drop rows with no orderid on ingest.'
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Locality hygiene.
|
||||
const loc = scanLocalities(rows);
|
||||
const localityIssues = [...loc.nonLatin, ...loc.unbalanced, ...loc.caseVariants.map((v) => v.join(' / '))];
|
||||
if (localityIssues.length) {
|
||||
findings.push({
|
||||
id: 'locality-hygiene',
|
||||
severity: 'medium',
|
||||
title: 'Locality is free text and does not normalise',
|
||||
detail:
|
||||
`${loc.distinct} distinct spellings across ${loc.total} orders. Mixed scripts, unbalanced brackets and case variants all appear. ` +
|
||||
'Dispatch buckets zones on this family of fields, so the noise reaches the plan.',
|
||||
evidence: localityIssues.slice(0, 5),
|
||||
orderIds: onlyOrders(rows)
|
||||
.filter((o) => localityIssues.some((v) => v.includes(String(readField(o, 'locality')).trim())))
|
||||
.map((o) => String(o.orderid)),
|
||||
fix: 'Resolve localities against a gazetteer at order creation; keep the typed string as a display alias.'
|
||||
});
|
||||
}
|
||||
|
||||
return findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Agent hand-off
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* The compact, pre-computed fact sheet an LLM should be given instead of raw
|
||||
* rows — small enough to fit a prompt, and already carrying the conclusions
|
||||
* that arithmetic can reach on its own.
|
||||
*
|
||||
* Keeping this separate from any transport is the point: when the ops-agent
|
||||
* endpoint lands, it POSTs this object. Nothing here needs to change.
|
||||
*/
|
||||
export const buildAgentContext = (rows, meta = {}) => {
|
||||
const summary = summariseDay(rows);
|
||||
return {
|
||||
meta: { generatedAt: new Date().toISOString(), ...meta },
|
||||
summary: {
|
||||
orders: summary.orderCount,
|
||||
riders: summary.riders,
|
||||
trustworthyDeliveryTimes: summary.trustworthyDeliveryTimes,
|
||||
lifecycleCaptureRatio: Number(summary.lifecycleRatio.toFixed(3)),
|
||||
medianAssignDelayMin: summary.medianAssignDelayMin,
|
||||
totalKm: summary.totalKm,
|
||||
totalCharge: summary.totalCharge,
|
||||
timestampsTrustworthy: !summary.skew.suspected && summary.trustworthyDeliveryTimes === summary.deliveryStampCount
|
||||
},
|
||||
zones: zoneRollup(rows),
|
||||
findings: scanDataQuality(rows).map(({ id, severity, title, detail, evidence, orderIds }) => ({
|
||||
id,
|
||||
severity,
|
||||
title,
|
||||
detail,
|
||||
evidence,
|
||||
orderCount: orderIds.length
|
||||
})),
|
||||
caveats: [
|
||||
'Delivery timestamps flagged by a bulk-close finding must not be used for duration or SLA claims.',
|
||||
'Where an offset-skew finding is present, promised slots are ambiguous; state the assumption used.',
|
||||
'Rollups over a single day and a single tenant are descriptive, not evidence of a trend.'
|
||||
]
|
||||
};
|
||||
};
|
||||
370
src/pages/nearle/ai/opsAnalysis.test.js
Normal file
370
src/pages/nearle/ai/opsAnalysis.test.js
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Tests for the ops analysis layer.
|
||||
*
|
||||
* The fixture is the real 2 Sep 2026 day for tenant Bawa Medical, Nagercoil,
|
||||
* taken from the Orders_Detail export — 12 orders, one rider, plus the TOTAL
|
||||
* row the CSV export appends. Using real data matters here: every detector in
|
||||
* opsAnalysis.js exists because of something this specific day exposed, and a
|
||||
* synthetic fixture would let a detector pass while missing the case it was
|
||||
* written for.
|
||||
*
|
||||
* The most important test in this file is the last one in each detector's
|
||||
* block: once the backend is fixed, the detector must go QUIET. A detector that
|
||||
* keeps firing after the fix would have its corrections applied twice.
|
||||
*/
|
||||
|
||||
import {
|
||||
onlyOrders,
|
||||
wallClockMinutes,
|
||||
detectOffsetSkew,
|
||||
detectBulkClose,
|
||||
lifecycleCapture,
|
||||
detectOutOfOrderEvents,
|
||||
scanLocalities,
|
||||
zoneRollup,
|
||||
summariseDay,
|
||||
scanDataQuality,
|
||||
buildAgentContext,
|
||||
IST_OFFSET_MIN
|
||||
} from './opsAnalysis';
|
||||
|
||||
// Shape as the Jupiter API sends it: `deliverydate` is an offset-labelled ISO
|
||||
// string, the lifecycle stamps are naive local strings.
|
||||
const order = (orderid, deliverydate, assigntime, deliverytime, extra = {}) => ({
|
||||
orderid,
|
||||
tenantname: 'Bawa medical Nagercoil',
|
||||
rider: 'Sivakumar Subramani',
|
||||
orderstatus: 'delivered',
|
||||
deliverydate: `2026-09-02T${deliverydate}:00+05:30`,
|
||||
assigntime: assigntime ? `2026-09-02 ${assigntime}` : '',
|
||||
starttime: '',
|
||||
arrivaltime: '',
|
||||
pickuptime: '',
|
||||
deliverytime: deliverytime ? `2026-09-02 ${deliverytime}` : '',
|
||||
canceltime: '',
|
||||
...extra
|
||||
});
|
||||
|
||||
const KN = { deliverysuburb: 'King Nagar' };
|
||||
const WC = { deliverysuburb: 'Weavers Colony' };
|
||||
|
||||
const DAY = [
|
||||
order('908-20246318', '14:00', '19:20:42', '22:59:24', { ...KN, kms: '3', deliverycharge: '60', deliverylocation: 'Parvathipuram' }),
|
||||
order('908-20246317', '11:30', '19:20:42', '22:59:33', { ...KN, kms: '1', deliverycharge: '60', deliverylocation: 'Puthukudierupu' }),
|
||||
order('908-20246316', '09:30', '14:36:45', '22:59:42', { ...KN, kms: '3', deliverycharge: '60', deliverylocation: 'CBH ROAD' }),
|
||||
order('908-20246315', '07:30', '13:26:51', '22:59:51', { ...KN, kms: '4', deliverycharge: '60', deliverylocation: 'NGL' }),
|
||||
order('908-20246313', '07:30', '13:26:51', '22:59:59', { ...KN, kms: '3', deliverycharge: '60', deliverylocation: 'Erulapappuram' }),
|
||||
order('908-20246314', '07:30', '13:26:51', '23:00:07', { ...KN, kms: '2', deliverycharge: '60', deliverylocation: 'Ramavarmapuram' }),
|
||||
order('908-20246312', '07:30', '12:39:45', '23:00:18', { ...WC, kms: '1', deliverycharge: '60', deliverylocation: 'Simon Nagar' }),
|
||||
order('908-20246308', '05:30', '11:43:03', '23:00:25', { ...WC, kms: '1', deliverycharge: '60', deliverylocation: 'ராமவர்மபுரம்' }),
|
||||
order('908-20246309', '06:00', '11:43:03', '23:00:34', { ...KN, kms: '6', deliverycharge: '84', deliverylocation: 'Erumbukadu (post' }),
|
||||
order('908-20246310', '06:00', '11:43:03', '23:00:42', { ...KN, kms: '4', deliverycharge: '60', deliverylocation: 'Kurusady' }),
|
||||
order('908-20246311', '06:00', '11:43:03', '23:00:50', { ...KN, kms: '5', deliverycharge: '72', deliverylocation: 'NGL' }),
|
||||
// The only order with intermediate stamps — and they contradict each other:
|
||||
// arrival and pickup both land before start.
|
||||
order('908-20246307', '04:30', '10:04:53', '10:50:56', {
|
||||
...KN,
|
||||
kms: '3',
|
||||
deliverycharge: '60',
|
||||
deliverylocation: 'Udhayamarthandam',
|
||||
starttime: '2026-09-02 10:50:40',
|
||||
arrivaltime: '2026-09-02 10:33:25',
|
||||
pickuptime: '2026-09-02 10:33:38'
|
||||
})
|
||||
];
|
||||
|
||||
// The TOTAL row the CSV export appends: no orderid, carries the column sums.
|
||||
const TOTAL_ROW = { orderid: '', ordernotes: 'TOTAL', kms: '36', deliverycharge: '756' };
|
||||
const RAW_EXPORT = [...DAY, TOTAL_ROW];
|
||||
|
||||
const clock = (minutes) => {
|
||||
const m = Math.round(minutes) % 1440;
|
||||
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}:00`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The same day as it would look after the backend is fixed: slots stored in
|
||||
* IST, deliveries stamped as they happened, and lifecycle stamps in order.
|
||||
*
|
||||
* Deliveries are staggered by index so that four orders sharing a 13:00 slot do
|
||||
* not land on the same second and trip the bulk-close detector for the wrong
|
||||
* reason — that detector should stay quiet here because the data is genuinely
|
||||
* fine, not because the fixture dodged it.
|
||||
*/
|
||||
const FIXED_DAY = DAY.map((o, i) => {
|
||||
const slot = (wallClockMinutes(o.deliverydate) + IST_OFFSET_MIN) % 1440;
|
||||
const delivered = slot + 40 + i * 7;
|
||||
const intermediates = o.starttime
|
||||
? { starttime: `2026-09-02 ${clock(slot + 15)}`, arrivaltime: `2026-09-02 ${clock(slot + 30)}`, pickuptime: `2026-09-02 ${clock(slot + 33)}` }
|
||||
: {};
|
||||
return {
|
||||
...o,
|
||||
deliverydate: `2026-09-02T${clock(slot)}+05:30`,
|
||||
assigntime: `2026-09-02 ${clock(slot + 10)}`,
|
||||
deliverytime: `2026-09-02 ${clock(delivered)}`,
|
||||
...intermediates
|
||||
};
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Row hygiene
|
||||
// ==========================================================================
|
||||
|
||||
describe('onlyOrders', () => {
|
||||
it('drops the TOTAL row the CSV export appends', () => {
|
||||
expect(RAW_EXPORT).toHaveLength(13);
|
||||
expect(onlyOrders(RAW_EXPORT)).toHaveLength(12);
|
||||
expect(onlyOrders(RAW_EXPORT).every((o) => o.orderid)).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates a missing or non-array input', () => {
|
||||
expect(onlyOrders(undefined)).toEqual([]);
|
||||
expect(onlyOrders(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wallClockMinutes', () => {
|
||||
it('reads the literal clock, ignoring the timezone label', () => {
|
||||
// The whole offset question depends on NOT converting here.
|
||||
expect(wallClockMinutes('2026-09-02T04:30:00+05:30')).toBe(270);
|
||||
expect(wallClockMinutes('2026-09-02 10:04:53')).toBeCloseTo(604.88, 1);
|
||||
});
|
||||
|
||||
it('returns null for empty and unparseable values', () => {
|
||||
expect(wallClockMinutes('')).toBeNull();
|
||||
expect(wallClockMinutes(null)).toBeNull();
|
||||
expect(wallClockMinutes('not a time')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Offset skew
|
||||
// ==========================================================================
|
||||
|
||||
describe('detectOffsetSkew', () => {
|
||||
it('flags the day: median assign-minus-slot lands on the IST offset', () => {
|
||||
const skew = detectOffsetSkew(RAW_EXPORT);
|
||||
expect(skew.suspected).toBe(true);
|
||||
expect(skew.sampleSize).toBe(12);
|
||||
// 343 minutes — 13 past the 330-minute offset.
|
||||
expect(skew.medianDeltaMin).toBe(343);
|
||||
expect(skew.suggestedShiftMin).toBe(IST_OFFSET_MIN);
|
||||
});
|
||||
|
||||
it('goes quiet once slots are stored in IST', () => {
|
||||
const skew = detectOffsetSkew(FIXED_DAY);
|
||||
expect(skew.suspected).toBe(false);
|
||||
expect(skew.suggestedShiftMin).toBe(0);
|
||||
});
|
||||
|
||||
it('does not fire on a genuinely chaotic day with a wide spread', () => {
|
||||
const chaotic = DAY.map((o, i) => ({ ...o, assigntime: `2026-09-02 ${String(6 + i).padStart(2, '0')}:00:00` }));
|
||||
expect(detectOffsetSkew(chaotic).suspected).toBe(false);
|
||||
});
|
||||
|
||||
it('will not judge from too few orders', () => {
|
||||
expect(detectOffsetSkew(DAY.slice(0, 3)).suspected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Bulk close
|
||||
// ==========================================================================
|
||||
|
||||
describe('detectBulkClose', () => {
|
||||
it('finds the 23:00 close-out and excludes the one genuine delivery', () => {
|
||||
const clusters = detectBulkClose(RAW_EXPORT);
|
||||
expect(clusters).toHaveLength(1);
|
||||
const [c] = clusters;
|
||||
expect(c.count).toBe(11);
|
||||
expect(c.spanSeconds).toBe(86);
|
||||
expect(c.medianGapSeconds).toBeLessThanOrEqual(11);
|
||||
// …307 was delivered at 10:50 and must not be swept into the cluster.
|
||||
expect(c.orderIds).not.toContain('908-20246307');
|
||||
});
|
||||
|
||||
it('goes quiet when deliveries are spread across the day', () => {
|
||||
expect(detectBulkClose(FIXED_DAY)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('respects the minimum cluster size so a genuine double-drop is not flagged', () => {
|
||||
const twoAtOnce = [
|
||||
order('A-1', '10:00', '09:00:00', '12:00:00'),
|
||||
order('A-2', '10:00', '09:00:00', '12:00:05'),
|
||||
order('A-3', '11:00', '09:00:00', '14:30:00')
|
||||
];
|
||||
expect(detectBulkClose(twoAtOnce)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Lifecycle
|
||||
// ==========================================================================
|
||||
|
||||
describe('lifecycleCapture', () => {
|
||||
it('counts stamps per stage across the day', () => {
|
||||
const cap = lifecycleCapture(RAW_EXPORT);
|
||||
expect(cap.orderCount).toBe(12);
|
||||
expect(cap.stages.assigntime).toBe(12);
|
||||
expect(cap.stages.deliverytime).toBe(12);
|
||||
expect(cap.stages.starttime).toBe(1);
|
||||
expect(cap.stages.arrivaltime).toBe(1);
|
||||
expect(cap.stages.pickuptime).toBe(1);
|
||||
expect(cap.captured).toBe(27);
|
||||
expect(cap.expected).toBe(60);
|
||||
expect(cap.ratio).toBeCloseTo(0.45, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectOutOfOrderEvents', () => {
|
||||
it('catches arrival and pickup recorded before start on …307', () => {
|
||||
const v = detectOutOfOrderEvents(RAW_EXPORT);
|
||||
expect(v.length).toBeGreaterThan(0);
|
||||
expect(v.every((x) => x.orderid === '908-20246307')).toBe(true);
|
||||
expect(v[0]).toMatchObject({ earlier: 'arrival', later: 'start' });
|
||||
});
|
||||
|
||||
it('is silent on an order whose stamps run forwards', () => {
|
||||
const clean = [
|
||||
order('B-1', '10:00', '09:00:00', '11:00:00', {
|
||||
starttime: '2026-09-02 09:30:00',
|
||||
arrivaltime: '2026-09-02 09:45:00',
|
||||
pickuptime: '2026-09-02 09:50:00'
|
||||
})
|
||||
];
|
||||
expect(detectOutOfOrderEvents(clean)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Hygiene and rollups
|
||||
// ==========================================================================
|
||||
|
||||
describe('scanLocalities', () => {
|
||||
it('separates script, bracket and case problems', () => {
|
||||
const loc = scanLocalities(RAW_EXPORT);
|
||||
expect(loc.nonLatin).toContain('ராமவர்மபுரம்');
|
||||
expect(loc.unbalanced).toContain('Erumbukadu (post');
|
||||
expect(loc.total).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
// The orders-detail endpoint and the CSV export disagree on field names. This
|
||||
// was found by running the drawer against the live console, where every rider
|
||||
// read as absent and the day settled at ₹0.
|
||||
describe('field aliases', () => {
|
||||
const apiShape = DAY.map(({ rider, deliverycharge, ...rest }) => ({
|
||||
...rest,
|
||||
ridername: rider,
|
||||
deliverycharges: deliverycharge
|
||||
}));
|
||||
|
||||
it('reads rider and charge from the API field names', () => {
|
||||
const s = summariseDay(apiShape);
|
||||
expect(s.riders).toEqual(['Sivakumar Subramani']);
|
||||
expect(s.totalCharge).toBe(756);
|
||||
expect(s.totalKm).toBe(36);
|
||||
});
|
||||
|
||||
it('still reads the CSV export field names', () => {
|
||||
const s = summariseDay(DAY);
|
||||
expect(s.riders).toEqual(['Sivakumar Subramani']);
|
||||
expect(s.totalCharge).toBe(756);
|
||||
});
|
||||
|
||||
it('rolls up zones identically under either shape', () => {
|
||||
expect(zoneRollup(apiShape)).toEqual(zoneRollup(DAY));
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoneRollup', () => {
|
||||
it('rolls up orders, distance and charge per zone', () => {
|
||||
const zones = zoneRollup(RAW_EXPORT);
|
||||
const kn = zones.find((z) => z.zone === 'King Nagar');
|
||||
const wc = zones.find((z) => z.zone === 'Weavers Colony');
|
||||
expect(kn).toMatchObject({ orders: 10, km: 34, charge: 636 });
|
||||
expect(wc).toMatchObject({ orders: 2, km: 2, charge: 120 });
|
||||
expect(kn.kmPerDrop).toBeCloseTo(3.4, 2);
|
||||
});
|
||||
|
||||
it('totals match the figures the export footer claims', () => {
|
||||
const zones = zoneRollup(RAW_EXPORT);
|
||||
expect(zones.reduce((s, z) => s + z.km, 0)).toBe(36);
|
||||
expect(zones.reduce((s, z) => s + z.charge, 0)).toBe(756);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Summary and scan
|
||||
// ==========================================================================
|
||||
|
||||
describe('summariseDay', () => {
|
||||
it('reports no trustworthy delivery times for this day', () => {
|
||||
const s = summariseDay(RAW_EXPORT);
|
||||
expect(s.orderCount).toBe(12);
|
||||
expect(s.deliveryStampCount).toBe(12);
|
||||
// 11 bulk-closed, and …307 contradicts its own event order.
|
||||
expect(s.trustworthyDeliveryTimes).toBe(0);
|
||||
expect(s.riders).toEqual(['Sivakumar Subramani']);
|
||||
expect(s.totalKm).toBe(36);
|
||||
expect(s.totalCharge).toBe(756);
|
||||
});
|
||||
|
||||
it('reports assignment as healthy once the skew is accounted for', () => {
|
||||
const s = summariseDay(RAW_EXPORT);
|
||||
expect(s.skew.suspected).toBe(true);
|
||||
expect(s.medianAssignDelayMin).toBe(13);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanDataQuality', () => {
|
||||
it('ranks blocking findings ahead of hygiene', () => {
|
||||
const findings = scanDataQuality(RAW_EXPORT);
|
||||
const ids = findings.map((f) => f.id);
|
||||
expect(ids).toContain('bulk-close-0');
|
||||
expect(ids).toContain('offset-skew');
|
||||
expect(findings[0].severity).toBe('blocking');
|
||||
const severities = findings.map((f) => f.severity);
|
||||
expect(severities.indexOf('blocking')).toBeLessThan(severities.indexOf('medium'));
|
||||
});
|
||||
|
||||
it('reports the TOTAL row only when it is actually present', () => {
|
||||
expect(scanDataQuality(RAW_EXPORT).map((f) => f.id)).toContain('export-artifact');
|
||||
expect(scanDataQuality(DAY).map((f) => f.id)).not.toContain('export-artifact');
|
||||
});
|
||||
|
||||
it('gives every finding order IDs to point at, or none at all', () => {
|
||||
scanDataQuality(RAW_EXPORT).forEach((f) => {
|
||||
expect(Array.isArray(f.orderIds)).toBe(true);
|
||||
expect(typeof f.fix).toBe('string');
|
||||
expect(f.fix.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns nothing for an empty set', () => {
|
||||
expect(scanDataQuality([])).toEqual([]);
|
||||
expect(scanDataQuality([TOTAL_ROW])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAgentContext', () => {
|
||||
it('marks the day untrustworthy and carries caveats the model must respect', () => {
|
||||
const ctx = buildAgentContext(RAW_EXPORT, { tenant: 'Bawa medical Nagercoil', day: '2026-09-02' });
|
||||
expect(ctx.summary.timestampsTrustworthy).toBe(false);
|
||||
expect(ctx.summary.orders).toBe(12);
|
||||
expect(ctx.meta.tenant).toBe('Bawa medical Nagercoil');
|
||||
expect(ctx.zones.length).toBe(2);
|
||||
expect(ctx.caveats.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('carries no raw customer rows into the model context', () => {
|
||||
const ctx = buildAgentContext(RAW_EXPORT);
|
||||
const serialised = JSON.stringify(ctx);
|
||||
expect(serialised).not.toContain('deliverycontactno');
|
||||
expect(serialised).not.toContain('deliveryaddress');
|
||||
});
|
||||
|
||||
it('reports a fixed day as trustworthy', () => {
|
||||
expect(buildAgentContext(FIXED_DAY).summary.timestampsTrustworthy).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -329,8 +329,11 @@ export const fetchRidersLogs = async ({ queryKey }) => {
|
||||
// riderSearch || ''
|
||||
// }`
|
||||
// );
|
||||
// v2 deliberately (the v1 route returns a different shape) -- but via the
|
||||
// configured base rather than a hardcoded host, so a non-production build
|
||||
// does not read from live.
|
||||
const riderLogsResponse = await axios.get(
|
||||
`https://jupiter.nearle.app/live/api/v2/partners/getriderlogs/?applocationid=${appId}&fromdate=${startdate || ''}&todate=${
|
||||
`${process.env.REACT_APP_URL2}/partners/getriderlogs/?applocationid=${appId}&fromdate=${startdate || ''}&todate=${
|
||||
startdate || ''
|
||||
}&keyword=${riderSearch || ''}`
|
||||
);
|
||||
@@ -404,7 +407,10 @@ export const finalCreatedeliveries = async (deliveryData) => {
|
||||
userid: toInt(d.userid),
|
||||
rider_id: toInt(d.rider_id)
|
||||
}));
|
||||
const response = await axios.post(`https://jupiter.nearle.app/live/api/v1/deliveries/createdeliveries`, deliveries);
|
||||
// Use the configured base, NOT a hardcoded host: this call commits real
|
||||
// deliveries, and hardcoding the live host meant a staging build wrote to
|
||||
// production.
|
||||
const response = await axios.post(`${process.env.REACT_APP_URL}/deliveries/createdeliveries`, deliveries);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
282
src/pages/nearle/orders/WeeklyIntake.js
Normal file
282
src/pages/nearle/orders/WeeklyIntake.js
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Weekly Order Intake — dry run (phases 0 and 1).
|
||||
*
|
||||
* Upload the weekly sheet, see exactly what would happen to every row. This
|
||||
* screen deliberately has **no commit control**: it reads a file, runs
|
||||
* parseIntakeFile, and reports. Creating orders is phase 3, and it needs
|
||||
* customer resolution and a role check before it can exist.
|
||||
*
|
||||
* The value of shipping the read-only half first is that an operator can fix a
|
||||
* bad week's file before anything is at stake, and we learn what real files
|
||||
* look like before writing the code that commits them.
|
||||
*/
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import { Box, Button, Chip, Paper, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tooltip, Typography } from '@mui/material';
|
||||
import { MdCloudUpload, MdDownload, MdErrorOutline, MdCheckCircle, MdWarningAmber, MdRestartAlt } from 'react-icons/md';
|
||||
import Papa from 'papaparse';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
import { INTAKE_COLUMNS, downloadTemplate } from '../ai/intakeTemplate';
|
||||
import { describeResult, parseIntakeFile, STATUS } from '../ai/intakeParser';
|
||||
import { OpenToast } from 'components/nearle_components/OpenToast';
|
||||
|
||||
const BRAND = '#662582';
|
||||
const BRAND_LIGHT = '#9255AB';
|
||||
const DT = {
|
||||
textPrimary: '#0f172a',
|
||||
textSecondary: '#64748b',
|
||||
textMuted: '#94a3b8',
|
||||
borderSubtle: '#e2e8f0',
|
||||
surface: '#ffffff',
|
||||
surfaceAlt: '#f8fafc'
|
||||
};
|
||||
|
||||
const STATUS_META = {
|
||||
[STATUS.OK]: { label: 'Ready', color: '#2c6b45', bg: '#dcece2', Icon: MdCheckCircle },
|
||||
[STATUS.REVIEW]: { label: 'Review', color: '#8a5600', bg: '#f8ead2', Icon: MdWarningAmber },
|
||||
[STATUS.REJECTED]: { label: 'Rejected', color: '#b3261e', bg: '#fae4e2', Icon: MdErrorOutline }
|
||||
};
|
||||
|
||||
// Bawa Medical, King Nagar. Phase 3 reads this from the selected pickup
|
||||
// location instead of a constant — until then the radius check needs an origin.
|
||||
const DEFAULT_HUB = { lat: 8.1841265, lng: 77.4318002 };
|
||||
const DEFAULT_RADIUS_KM = 25;
|
||||
|
||||
const Tile = ({ label, value, tone }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ flex: '1 1 120px', minWidth: 120, px: 1.75, py: 1.25, borderRadius: 2, border: '1px solid', borderColor: DT.borderSubtle, bgcolor: '#fff' }}
|
||||
>
|
||||
<Typography sx={{ fontSize: 10, letterSpacing: 0.6, textTransform: 'uppercase', color: DT.textMuted, fontWeight: 700 }}>{label}</Typography>
|
||||
<Typography sx={{ fontSize: 26, fontWeight: 700, lineHeight: 1.2, color: tone || DT.textPrimary, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export default function WeeklyIntake() {
|
||||
const [result, setResult] = useState(null);
|
||||
const [filename, setFilename] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const opentoast = (msg, variant, time) => OpenToast(msg, variant, time);
|
||||
|
||||
const run = (rows, name) => {
|
||||
const parsed = parseIntakeFile(rows, { serviceCentre: DEFAULT_HUB, serviceRadiusKm: DEFAULT_RADIUS_KM });
|
||||
setResult(parsed);
|
||||
setFilename(name);
|
||||
const summary = describeResult(parsed);
|
||||
opentoast(summary, parsed.missingColumns.length || parsed.summary.rejected ? 'warning' : 'success', 4000);
|
||||
};
|
||||
|
||||
const onFile = (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const lower = file.name.toLowerCase();
|
||||
const isCSV = lower.endsWith('.csv');
|
||||
const isExcel = lower.endsWith('.xls') || lower.endsWith('.xlsx');
|
||||
if (!isCSV && !isExcel) {
|
||||
opentoast('Upload a .csv, .xls or .xlsx file.', 'warning', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCSV) {
|
||||
Papa.parse(file, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (res) => run(res.data || [], file.name),
|
||||
error: (err) => opentoast(`Could not read the file: ${err.message}`, 'error', 4000)
|
||||
});
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const wb = XLSX.read(e.target.result, { type: 'array' });
|
||||
run(XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { defval: '' }), file.name);
|
||||
} catch (err) {
|
||||
opentoast(`Could not read the workbook: ${err.message}`, 'error', 4000);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
// Allow re-selecting the same file after an edit.
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setResult(null);
|
||||
setFilename('');
|
||||
};
|
||||
|
||||
const s = result?.summary;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ p: { xs: 1.5, md: 2 }, mb: 1.5, borderRadius: 2, border: '1px solid', borderColor: DT.borderSubtle, bgcolor: '#fff' }}>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} alignItems={{ md: 'center' }} justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: 20, fontWeight: 800, color: DT.textPrimary, lineHeight: 1.25 }}>Weekly Order Intake</Typography>
|
||||
<Typography sx={{ fontSize: 12.5, color: DT.textSecondary }}>
|
||||
{filename ? `Checked ${filename}` : 'Dry run — check a weekly file before anything is created'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
<Tooltip title="Download the blank intake template" arrow>
|
||||
<Button
|
||||
onClick={() => downloadTemplate()}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disableElevation
|
||||
startIcon={<MdDownload size={15} />}
|
||||
sx={{
|
||||
height: 34, px: 1.5, borderRadius: 1.5, textTransform: 'none', fontSize: 12.5, fontWeight: 700,
|
||||
bgcolor: '#fff', borderColor: DT.borderSubtle, color: DT.textPrimary,
|
||||
'&:hover': { bgcolor: '#fff', borderColor: BRAND, color: BRAND }
|
||||
}}
|
||||
>
|
||||
Template
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{result && (
|
||||
<Button
|
||||
onClick={reset}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disableElevation
|
||||
startIcon={<MdRestartAlt size={15} />}
|
||||
sx={{
|
||||
height: 34, px: 1.5, borderRadius: 1.5, textTransform: 'none', fontSize: 12.5, fontWeight: 700,
|
||||
bgcolor: '#fff', borderColor: DT.borderSubtle, color: DT.textPrimary,
|
||||
'&:hover': { bgcolor: '#fff', borderColor: BRAND, color: BRAND }
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disableElevation
|
||||
startIcon={<MdCloudUpload size={15} />}
|
||||
sx={{
|
||||
height: 34, px: 1.75, borderRadius: 1.5, textTransform: 'none', fontSize: 12.5, fontWeight: 700,
|
||||
bgcolor: BRAND, '&:hover': { bgcolor: BRAND_LIGHT }
|
||||
}}
|
||||
>
|
||||
Check a file
|
||||
</Button>
|
||||
<input ref={inputRef} type="file" accept=".csv,.xls,.xlsx" onChange={onFile} data-testid="intake-file" style={{ display: 'none' }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ px: 1.75, py: 1.25, mb: 1.5, borderRadius: 2, border: '1px solid', borderColor: DT.borderSubtle, borderLeft: `3px solid ${BRAND}`, bgcolor: '#fff' }}
|
||||
>
|
||||
<Typography sx={{ fontSize: 12.5, color: DT.textSecondary, lineHeight: 1.6 }}>
|
||||
<strong style={{ color: DT.textPrimary }}>This screen never creates orders.</strong> It reads the file and reports what would
|
||||
happen to each row. Creating orders needs customer matching and a pickup slot, which are not built yet.
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{!result ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 4, borderRadius: 2, border: '1px dashed', borderColor: DT.borderSubtle, bgcolor: '#fff', textAlign: 'center' }}
|
||||
>
|
||||
<MdCloudUpload size={32} color={DT.textMuted} />
|
||||
<Typography sx={{ fontSize: 15, fontWeight: 700, color: DT.textPrimary, mt: 1 }}>No file checked yet</Typography>
|
||||
<Typography sx={{ fontSize: 13, color: DT.textSecondary, mt: 0.5, maxWidth: 480, mx: 'auto' }}>
|
||||
Upload the week’s CSV or Excel file. Required columns are{' '}
|
||||
{INTAKE_COLUMNS.filter((c) => c.required).map((c) => c.label).join(', ')}.
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
<Tile label="Rows" value={s.total} />
|
||||
<Tile label="Ready" value={s.ok} tone={STATUS_META[STATUS.OK].color} />
|
||||
<Tile label="Need review" value={s.needsReview} tone={STATUS_META[STATUS.REVIEW].color} />
|
||||
<Tile label="Rejected" value={s.rejected} tone={STATUS_META[STATUS.REJECTED].color} />
|
||||
</Stack>
|
||||
|
||||
{result.missingColumns.length > 0 && (
|
||||
<Paper elevation={0} sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: '#f3c6c2', borderLeft: '3px solid #b3261e', bgcolor: '#fff' }}>
|
||||
<Typography sx={{ fontSize: 13, fontWeight: 700, color: '#b3261e' }}>Missing required columns</Typography>
|
||||
<Typography sx={{ fontSize: 12.5, color: DT.textSecondary, mt: 0.25 }}>
|
||||
{result.missingColumns.join(', ')} — no row can be created until these are present.
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{result.unknownColumns.length > 0 && (
|
||||
<Paper elevation={0} sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: DT.borderSubtle, borderLeft: '3px solid #8a5600', bgcolor: '#fff' }}>
|
||||
<Typography sx={{ fontSize: 13, fontWeight: 700, color: '#8a5600' }}>
|
||||
{result.unknownColumns.length} column{result.unknownColumns.length === 1 ? '' : 's'} not recognised — ignored
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: 12, color: DT.textSecondary, mt: 0.25, wordBreak: 'break-word' }}>
|
||||
{result.unknownColumns.join(', ')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} elevation={0} sx={{ borderRadius: 2, border: '1px solid', borderColor: DT.borderSubtle, bgcolor: '#fff' }}>
|
||||
<Table size="small" sx={{ minWidth: 820 }}>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: DT.surfaceAlt }}>
|
||||
{['Row', 'Status', 'Receiver', 'Phone', 'Address', 'Item', 'Qty', 'What needs attention'].map((h) => (
|
||||
<TableCell key={h} sx={{ fontSize: 10, letterSpacing: 0.5, textTransform: 'uppercase', color: DT.textMuted, fontWeight: 700 }}>
|
||||
{h}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{result.rows.map((row) => {
|
||||
const meta = STATUS_META[row.status];
|
||||
const n = row.normalised;
|
||||
return (
|
||||
<TableRow key={row.index} hover>
|
||||
<TableCell sx={{ fontSize: 12, color: DT.textMuted, fontVariantNumeric: 'tabular-nums' }}>{row.sheetRow}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={meta.label}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: 10.5, fontWeight: 700, bgcolor: meta.bg, color: meta.color, '& .MuiChip-label': { px: 0.9 } }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: 12.5, fontWeight: 600 }}>{n.receivername || <span style={{ color: DT.textMuted }}>—</span>}</TableCell>
|
||||
<TableCell sx={{ fontSize: 12.5, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{n.receiverphone || <span style={{ color: DT.textMuted }}>—</span>}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: 12, color: DT.textSecondary, maxWidth: 240 }}>{n.receiveraddress || '—'}</TableCell>
|
||||
<TableCell sx={{ fontSize: 12.5 }}>{n.itemdescription || <span style={{ color: DT.textMuted }}>—</span>}</TableCell>
|
||||
<TableCell sx={{ fontSize: 12.5, fontVariantNumeric: 'tabular-nums' }}>{n.quantity ?? '—'}</TableCell>
|
||||
<TableCell sx={{ fontSize: 12, color: DT.textSecondary, maxWidth: 300 }}>
|
||||
{row.reasons.length === 0 ? (
|
||||
<span style={{ color: DT.textMuted }}>Nothing</span>
|
||||
) : (
|
||||
row.reasons.map((r, i) => (
|
||||
<Box key={i} sx={{ mb: i < row.reasons.length - 1 ? 0.5 : 0 }}>
|
||||
<Box component="span" sx={{ color: r.level === 'reject' ? '#b3261e' : '#8a5600', fontWeight: 700, mr: 0.5 }}>
|
||||
{r.level === 'reject' ? 'Blocked:' : 'Check:'}
|
||||
</Box>
|
||||
{r.message}
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,7 @@ import { CSVExport } from 'components/third-party/ReactTable';
|
||||
import Loader from 'components/Loader';
|
||||
import { useDebounce } from 'components/nearle_components/useDebounce';
|
||||
import MapWithRoute from './mapWithRoute';
|
||||
import OpsAgentDrawer from '../ai/OpsAgentDrawer';
|
||||
|
||||
// ============================================================================
|
||||
// Design tokens — shared with the rest of the redesigned operator pages.
|
||||
@@ -304,6 +305,10 @@ export default function OrdersDetails() {
|
||||
const [totalAmount, settotalAmount] = useState(0);
|
||||
const [searchword, setSearchword] = useState('');
|
||||
|
||||
// Ops Agent — reads the rows already loaded here, so it inherits whatever
|
||||
// date range and status filter the operator has applied.
|
||||
const [opsOpen, setOpsOpen] = useState(false);
|
||||
|
||||
// Density toggle — operators choose how many rows to surface per screen.
|
||||
// Default 'compact' (rows ≈ 32px) so the table starts dense like Linear / Vercel.
|
||||
const [density, setDensity] = useState(() => {
|
||||
@@ -732,6 +737,37 @@ export default function OrdersDetails() {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Check this range for data-quality problems before trusting its numbers" arrow>
|
||||
<Button
|
||||
onClick={() => setOpsOpen(true)}
|
||||
disableElevation
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<MdInsights size={15} />}
|
||||
sx={{
|
||||
height: 32,
|
||||
px: 1.25,
|
||||
borderRadius: 1.5,
|
||||
textTransform: 'none',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.1,
|
||||
bgcolor: '#fff',
|
||||
borderColor: DT.borderSubtle,
|
||||
color: DT.textPrimary,
|
||||
'&:hover': {
|
||||
bgcolor: '#fff',
|
||||
borderColor: BRAND,
|
||||
color: BRAND,
|
||||
boxShadow: `0 0 0 3px ${ring(BRAND)}`
|
||||
},
|
||||
'&:focus-visible': { boxShadow: `0 0 0 3px ${ring(BRAND)}` }
|
||||
}}
|
||||
>
|
||||
Ops Agent
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Download — CSVExport already renders its own Button; style it directly so
|
||||
only ONE button paints in the action bar. Matches the contained brand look. */}
|
||||
<Box
|
||||
@@ -1628,6 +1664,19 @@ export default function OrdersDetails() {
|
||||
</Box>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
{/* ============================================= || Ops Agent Drawer || ============================================= */}
|
||||
<OpsAgentDrawer
|
||||
open={opsOpen}
|
||||
onClose={() => setOpsOpen(false)}
|
||||
rows={rows}
|
||||
totalAvailable={statusCount}
|
||||
meta={{
|
||||
tenant: rows[0]?.tenantname,
|
||||
range: startdate === enddate ? dayjs(startdate).format('DD MMM YYYY') : `${dayjs(startdate).format('DD MMM')} – ${dayjs(enddate).format('DD MMM YYYY')}`,
|
||||
status: currentStatus
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
// project import
|
||||
// import GuestGuard from 'utils/route-guard/GuestGuard';
|
||||
import GuestGuard from 'utils/route-guard/GuestGuard';
|
||||
import CommonLayout from 'layout/CommonLayout';
|
||||
import Loadable from 'components/Loadable';
|
||||
|
||||
@@ -22,9 +22,9 @@ const LoginRoutes = {
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
// <GuestGuard>
|
||||
<GuestGuard>
|
||||
<CommonLayout />
|
||||
// </GuestGuard>
|
||||
</GuestGuard>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -33,6 +33,7 @@ const CreateCustomer = Loadable(lazy(() => import('pages/nearle/clients/createCu
|
||||
|
||||
const Createorder1 = Loadable(lazy(() => import('pages/nearle/orders/createorder1')));
|
||||
const MultipleOrders = Loadable(lazy(() => import('pages/nearle/orders/multipleOrders')));
|
||||
const WeeklyIntake = Loadable(lazy(() => import('pages/nearle/orders/WeeklyIntake')));
|
||||
|
||||
const OrderSummary = Loadable(lazy(() => import('pages/nearle/reports/orderSummary')));
|
||||
const OrdersDetails = Loadable(lazy(() => import('pages/nearle/reports/ordersDetails')));
|
||||
@@ -53,9 +54,9 @@ const MainRoutes = {
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
// <AuthGuard>
|
||||
<MainLayout />
|
||||
// </AuthGuard>
|
||||
<AuthGuard>
|
||||
<MainLayout />
|
||||
</AuthGuard>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
@@ -76,6 +77,10 @@ const MainRoutes = {
|
||||
path: 'create/grouporders',
|
||||
element: <MultipleOrders />
|
||||
},
|
||||
{
|
||||
path: 'weekly-intake',
|
||||
element: <WeeklyIntake />
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
element: <Createorder1 />
|
||||
|
||||
@@ -2,29 +2,32 @@ import PropTypes from 'prop-types';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
// project import
|
||||
import useAuth from 'hooks/useAuth';
|
||||
import { isSessionValid } from './authState';
|
||||
|
||||
// ==============================|| AUTH GUARD ||============================== //
|
||||
|
||||
// Blocks a protected route when there is no valid session.
|
||||
//
|
||||
// Previously this used useAuth(), which throws unless JWTProvider wraps the
|
||||
// tree -- and that provider is commented out in App.js. That is why the guard
|
||||
// was disabled in the route files rather than fixed, leaving every route
|
||||
// publicly reachable. It now reads the same localStorage session the rest of
|
||||
// the app uses.
|
||||
//
|
||||
// Children are withheld while unauthenticated so a protected page cannot
|
||||
// paint (and fire its data queries) during the redirect.
|
||||
const AuthGuard = ({ children }) => {
|
||||
const { isLoggedIn } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const ok = isSessionValid();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) {
|
||||
navigate('login', {
|
||||
state: {
|
||||
from: location.pathname
|
||||
},
|
||||
replace: true
|
||||
});
|
||||
navigate('login', { replace: true });
|
||||
if (!ok) {
|
||||
navigate('/login', { state: { from: location.pathname }, replace: true });
|
||||
}
|
||||
}, [isLoggedIn, navigate, location]);
|
||||
}, [ok, navigate, location.pathname]);
|
||||
|
||||
return children;
|
||||
return ok ? children : null;
|
||||
};
|
||||
|
||||
AuthGuard.propTypes = {
|
||||
|
||||
@@ -2,28 +2,30 @@ import PropTypes from 'prop-types';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
// project import
|
||||
import { APP_DEFAULT_PATH } from 'config';
|
||||
import useAuth from 'hooks/useAuth';
|
||||
import { isSessionValid } from './authState';
|
||||
|
||||
// ==============================|| GUEST GUARD ||============================== //
|
||||
|
||||
// Keeps an already-signed-in user off the login page, sending them back to
|
||||
// wherever AuthGuard bounced them from (or the default landing path).
|
||||
//
|
||||
// Reads the same localStorage session as AuthGuard -- see authState.js for why
|
||||
// this no longer uses useAuth().
|
||||
const GuestGuard = ({ children }) => {
|
||||
const { isLoggedIn } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const ok = isSessionValid();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
navigate(location?.state?.from ? location?.state?.from : APP_DEFAULT_PATH, {
|
||||
state: {
|
||||
from: ''
|
||||
},
|
||||
replace: true
|
||||
});
|
||||
if (ok) {
|
||||
navigate(location?.state?.from || APP_DEFAULT_PATH, { state: { from: '' }, replace: true });
|
||||
}
|
||||
}, [isLoggedIn, navigate, location]);
|
||||
}, [ok, navigate, location]);
|
||||
|
||||
// Unlike AuthGuard, render children regardless: a signed-in user seeing the
|
||||
// login form for one frame is harmless, whereas blanking it would leave a
|
||||
// signed-out user staring at nothing if the redirect ever failed.
|
||||
return children;
|
||||
};
|
||||
|
||||
|
||||
25
src/utils/route-guard/authState.js
Normal file
25
src/utils/route-guard/authState.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// Single source of truth for "is this session still valid".
|
||||
//
|
||||
// The app authenticates via localStorage (login.js writes authname/tenantid/
|
||||
// applocationid/...), NOT via the JWTContext this template shipped with --
|
||||
// JWTProvider is commented out in App.js, so useAuth() throws. The guards
|
||||
// therefore read localStorage directly, using the same 1-hour window App.js
|
||||
// already enforces on its interval, so both agree on when a session is over.
|
||||
|
||||
export const SESSION_MAX_AGE_MS = 60 * 60 * 1000;
|
||||
|
||||
export const isSessionValid = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
if (!localStorage.getItem('authname')) return false;
|
||||
const started = Number(localStorage.getItem('sessionStartTime'));
|
||||
// A missing/unparseable start time means a session written before this
|
||||
// check existed. Treat it as valid rather than logging the user out --
|
||||
// App.js stamps one on mount.
|
||||
if (!Number.isFinite(started) || started <= 0) return true;
|
||||
return Date.now() - started < SESSION_MAX_AGE_MS;
|
||||
} catch (e) {
|
||||
// Private mode or blocked storage: fail closed.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
70
src/utils/route-guard/authState.test.js
Normal file
70
src/utils/route-guard/authState.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Session-validity tests. This function now gates every protected route, so
|
||||
* a wrong answer either locks out a legitimate operator or leaves the console
|
||||
* publicly reachable.
|
||||
*/
|
||||
|
||||
import { isSessionValid, SESSION_MAX_AGE_MS } from './authState';
|
||||
|
||||
const setSession = (authname, startedMsAgo) => {
|
||||
localStorage.clear();
|
||||
if (authname !== null) localStorage.setItem('authname', authname);
|
||||
if (startedMsAgo !== undefined) localStorage.setItem('sessionStartTime', String(Date.now() - startedMsAgo));
|
||||
};
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
describe('isSessionValid', () => {
|
||||
it('rejects when nothing is stored', () => {
|
||||
expect(isSessionValid()).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when authname is absent even if a start time exists', () => {
|
||||
localStorage.setItem('sessionStartTime', String(Date.now()));
|
||||
expect(isSessionValid()).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a fresh session', () => {
|
||||
setSession('operator', 1000);
|
||||
expect(isSessionValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a session just inside the window', () => {
|
||||
setSession('operator', SESSION_MAX_AGE_MS - 5000);
|
||||
expect(isSessionValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a session past the window', () => {
|
||||
setSession('operator', SESSION_MAX_AGE_MS + 1000);
|
||||
expect(isSessionValid()).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects exactly at the boundary', () => {
|
||||
setSession('operator', SESSION_MAX_AGE_MS);
|
||||
expect(isSessionValid()).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts when the start time is missing, rather than logging the user out', () => {
|
||||
// A session written before this check existed. App.js stamps one on mount.
|
||||
localStorage.setItem('authname', 'operator');
|
||||
expect(isSessionValid()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([['not-a-number'], [''], ['0'], ['-1']])('accepts an unparseable start time (%p)', (value) => {
|
||||
localStorage.setItem('authname', 'operator');
|
||||
localStorage.setItem('sessionStartTime', value);
|
||||
expect(isSessionValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when storage throws', () => {
|
||||
const spy = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('SecurityError: storage blocked');
|
||||
});
|
||||
expect(isSessionValid()).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('matches the 1-hour window App.js enforces', () => {
|
||||
expect(SESSION_MAX_AGE_MS).toBe(60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user