diff --git a/src/pages/api/api.js b/src/pages/api/api.js index 8361e6a..d4ac97b 100644 --- a/src/pages/api/api.js +++ b/src/pages/api/api.js @@ -314,6 +314,7 @@ export const fetchCountAPI = async (appId, userid, startdate, enddate, rowsPerPa const response = await axios.get(url); const data = response.data.details; return { + total: data.total, uncoveredLength: data.pending, assignedLength: data.accepted, arrivedLength: data.arrived, diff --git a/src/pages/nearle/deliveries/deliveries.js b/src/pages/nearle/deliveries/deliveries.js index 7741b27..eb6fab6 100644 --- a/src/pages/nearle/deliveries/deliveries.js +++ b/src/pages/nearle/deliveries/deliveries.js @@ -15,9 +15,6 @@ import { MdWbSunny, MdNightsStay, MdCheck, - MdTrendingUp, - MdTrendingDown, - MdTrendingFlat, MdStorefront, MdLocationOn, MdDirectionsBike, @@ -87,7 +84,6 @@ import { changeRiderAPI, fetchCountAPI, fetchDeliveries, - fetchPercentageAPI, fetchRidersList, notifyRider, updateDeliveryAPI, @@ -559,13 +555,9 @@ const Deliveries = () => { /* ============================================= || fetchDeliveries | ============================================= */ const { - data: deliveriesData, isLoading: fetchDeliveriesIsLoading, isError: fetchDeliveriesIsError, error: fetchDeliveriesError, - fetchNextPage, - hasNextPage, - isFetchingNextPage, refetch: fetchDeliveriesRefetch } = useInfiniteQuery({ queryKey: [ @@ -584,7 +576,6 @@ const Deliveries = () => { queryFn: fetchDeliveries, getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined }); - const rows = deliveriesData?.pages.flatMap((page) => page.rows) || []; // (filteredRows is defined below, after countSourceRows / countSourceLoading // are in scope — they're the single source of truth for both the table and @@ -778,21 +769,6 @@ const Deliveries = () => { /* ============================================= || fetchPercentageAPI | ============================================= */ - const { - data: percentageData, - isLoading: fetchPercentageIsLoading, - isError: fetchPercentageIsError, - error: fetchPercentageError - } = useQuery({ - queryKey: ['fetchpercentageaPI', appId], - queryFn: () => fetchPercentageAPI(appId) - }); - useEffect(() => { - if (percentageData) { - console.log('percentageData', percentageData); - } - }, [percentageData]); - /* ============================================= || fetchcount | ============================================= */ const { @@ -851,18 +827,16 @@ const Deliveries = () => { }); const errorMessage = fetchDeliveriesIsError - ? `Error fetching percentages: ${fetchDeliveriesError?.message}` - : fetchPercentageIsError - ? `Error fetching percentages: ${fetchPercentageError?.message}` - : fetchCountIsError - ? `Error fetching percentages: ${fetchCountError?.message}` - : ridersListIsError - ? `Error fetching percentages: ${ridersListError?.message}` - : fetchtenantsIsError - ? `Error tenant list: ${fetchtenantsError?.message}` - : fetchlocationsIsError - ? `Error location list: ${fetchlocationsError?.message}` - : null; + ? `Error fetching deliveries: ${fetchDeliveriesError?.message}` + : fetchCountIsError + ? `Error fetching count summary: ${fetchCountError?.message}` + : ridersListIsError + ? `Error fetching riders: ${ridersListError?.message}` + : fetchtenantsIsError + ? `Error tenant list: ${fetchtenantsError?.message}` + : fetchlocationsIsError + ? `Error location list: ${fetchlocationsError?.message}` + : null; if (errorMessage) { console.log('errorMessage', errorMessage); @@ -873,7 +847,6 @@ const Deliveries = () => { return ( <> {(fetchCountIsLoading || - fetchPercentageIsLoading || countSourceIsLoading || fetchtenantsIsLoading || fetchlocationsIsLoading || @@ -891,7 +864,6 @@ const Deliveries = () => { }} open={ fetchCountIsLoading || - fetchPercentageIsLoading || fetchDeliveriesIsLoading || fetchtenantsIsLoading || fetchlocationsIsLoading || @@ -979,16 +951,14 @@ const Deliveries = () => { {/* ============================================= || KPI Cards | ============================================= */} {[ - { ...KPI_META[0], value: percentageData?.uncoveredOrders, percentage: percentageData?.percentage1 }, - { ...KPI_META[1], value: percentageData?.assignedOrders, percentage: percentageData?.percentage2 }, - { ...KPI_META[2], value: percentageData?.pickedOrders, percentage: percentageData?.percentage3 }, - { ...KPI_META[3], value: percentageData?.coveredOrders, percentage: percentageData?.percentage4 } + { ...KPI_META[0], value: countData?.total, percentage: null }, + { ...KPI_META[1], value: countData?.uncoveredLength, percentage: countData?.total ? Math.round((countData.uncoveredLength / countData.total) * 100) : 0 }, + { ...KPI_META[2], value: countData?.coveredLength, percentage: countData?.total ? Math.round((countData.coveredLength / countData.total) * 100) : 0 }, + { ...KPI_META[3], value: countData?.cancelLength, percentage: countData?.total ? Math.round((countData.cancelLength / countData.total) * 100) : 0 } ].map((item) => { const Icon = item.icon; const pct = typeof item.percentage === 'number' ? item.percentage : Number(item.percentage); const hasPct = !Number.isNaN(pct) && item.percentage !== undefined && item.percentage !== null; - const Trend = !hasPct ? MdTrendingFlat : pct > 0 ? MdTrendingUp : pct < 0 ? MdTrendingDown : MdTrendingFlat; - const trendColor = !hasPct || pct === 0 ? DT.textMuted : pct > 0 ? '#10b981' : '#ef4444'; return ( { > {item.label} - {fetchPercentageIsLoading ? ( + {fetchCountIsLoading ? ( ) : ( { px: 0.75, py: 0.25, borderRadius: 999, - bgcolor: soft(trendColor), - color: trendColor, + bgcolor: soft(item.color), + color: item.color, fontSize: { xs: 10, sm: 11 }, fontWeight: 800 }} > - {Math.abs(pct)}% - vs. yesterday + of total )} diff --git a/src/pages/nearle/dispatch/Dispatch.css b/src/pages/nearle/dispatch/Dispatch.css index 76efa51..82b7197 100644 --- a/src/pages/nearle/dispatch/Dispatch.css +++ b/src/pages/nearle/dispatch/Dispatch.css @@ -2161,11 +2161,18 @@ overflow: hidden; } +/* Trip-header — badge + stats + sort toggle in one row. + Layout: `flex-start` with a `gap` so badge and stats stay hugged + together on the left; `margin-left: auto` on the toggle pushes it + to the right. `flex-wrap` so the toggle drops to a second line on + narrow sidebars instead of squeezing the stats. */ .dispatch-container .trip-header { - padding: 12px 16px; + padding: 10px 14px; display: flex; - justify-content: space-between; + justify-content: flex-start; align-items: center; + gap: 10px; + flex-wrap: wrap; border-bottom: 1px solid var(--border); background: #fff; } @@ -2176,6 +2183,8 @@ font-size: 11px; font-weight: 800; color: #fff; + flex-shrink: 0; + letter-spacing: 0.02em; } .dispatch-container .trip-stats { @@ -2183,7 +2192,121 @@ font-weight: 600; color: var(--text-muted); display: flex; - gap: 12px; + gap: 10px; + flex-shrink: 0; +} + +/* Trip-card sort toggle — full-width segmented control. + Wraps onto its own row below the badge + stats (the trip-header is + `flex-wrap: wrap`, and `flex-basis: 100%` here forces the break). + Each pill = 50% of the row → big symmetric tap targets that read as + a clear "tab bar" instead of a discreet right-side switch. Active + state is a clean white "thumb" with the mode's semantic color + applied to the icon (indigo for Planned, emerald for By time), so + you can tell the mode at a glance from across the screen without + reading the labels. */ +.dispatch-container .trip-sort-toggle { + display: flex; + align-items: stretch; + width: 100%; + flex-basis: 100%; + margin-left: 0; + padding: 3px; + background: #f1f5f9; + border-radius: 10px; + position: relative; + isolation: isolate; +} + +.dispatch-container .trip-sort-pill { + flex: 1 1 0; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 10px; + min-height: 28px; + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.01em; + line-height: 1; + color: #64748b; + background: transparent; + border: 0; + border-radius: 7px; + cursor: pointer; + white-space: nowrap; + transition: color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; + -webkit-appearance: none; + appearance: none; +} + +.dispatch-container .trip-sort-pill svg { + width: 14px; + height: 14px; + flex: 0 0 auto; + display: block; + color: #94a3b8; + transition: color 0.18s ease, transform 0.18s ease; +} + +.dispatch-container .trip-sort-pill:hover:not(.is-active) { + color: #0f172a; +} + +.dispatch-container .trip-sort-pill:hover:not(.is-active) svg { + color: #475569; +} + +.dispatch-container .trip-sort-pill:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.4); +} + +.dispatch-container .trip-sort-pill.is-active { + color: #0f172a; + background: #ffffff; + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.08), + 0 0 0 1px rgba(15, 23, 42, 0.04), + 0 2px 6px rgba(15, 23, 42, 0.06); +} + +/* Mode-semantic accent on the active pill's icon — picks up the same + indigo / emerald used on the Compare control + the time-rank badge + below, so the page reads as a coherent system. */ +.dispatch-container .trip-sort-toggle[data-mode='planned'] + .trip-sort-pill.is-active svg { + color: #6366f1; +} + +.dispatch-container .trip-sort-toggle[data-mode='time'] + .trip-sort-pill.is-active svg { + color: #10b981; +} + +/* Time-mode visual cues on individual order cards. Numbered badge + switches from "planned step N" to "delivered #N inside this trip" + when sort mode is `time`. Emerald (delivered family) signals + "ranked by completion," distinguishing it from the planned step. */ +.dispatch-container .zone-order-num.is-time-rank { + background: #ecfdf5; + color: #047857; + box-shadow: inset 0 0 0 1.5px #6ee7b7; +} + +/* Cards whose order isn't yet delivered, while time-sort is active. + These are pushed to the end of the trip list (MAX_SAFE_INTEGER sort + key) — the muted styling tells the operator "not yet delivered" + at a glance without reading every status pill. */ +.dispatch-container .zone-order-card.is-pending-time { + opacity: 0.72; +} + +.dispatch-container .zone-order-card.is-pending-time .zone-order-num.is-time-rank { + background: #f8fafc; + color: #64748b; + box-shadow: inset 0 0 0 1.5px #cbd5e1; } .dispatch-container .step-wrap { @@ -5523,6 +5646,37 @@ text-overflow: ellipsis; } +/* Delivery customer line — sits directly under the rider name in + the popup header. Visual rank: bigger/bolder than .pu-delivery-id + (which is just a backend reference), smaller/lighter than .pu-rider + (the primary identity). One-line, ellipsis-truncated for long + customer names, with the full name in the title tooltip. */ +.dispatch-container .dispatch-popup .pu-customer { + margin-top: 4px; + padding: 0; + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.92); + display: flex; + align-items: center; + gap: 6px; + letter-spacing: -0.005em; + max-width: 100%; +} + +.dispatch-container .dispatch-popup .pu-customer svg { + font-size: 14px; + opacity: 0.85; + flex: 0 0 auto; +} + +.dispatch-container .dispatch-popup .pu-customer span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + .dispatch-container .dispatch-popup .pu-delivery-id { margin-top: 6px; font-size: 11px; diff --git a/src/pages/nearle/dispatch/Dispatch.js b/src/pages/nearle/dispatch/Dispatch.js index 60e2d80..6c1cb9c 100644 --- a/src/pages/nearle/dispatch/Dispatch.js +++ b/src/pages/nearle/dispatch/Dispatch.js @@ -271,98 +271,244 @@ function polylineLengthKm(points) { return total; } -// ─── Kalman filter for GPS pings ───────────────────────────────────────── +// ─── Kalman filter + RTS smoother for GPS pings ────────────────────────── // // Two independent 1D Kalman filters (one for lat, one for lng) applied to a -// chronologically sorted list of GPS pings. Per-axis state: [position, -// velocity]. Constant-velocity dynamics with random acceleration as process -// noise; measurement model H = [1, 0] (we measure position only). +// chronologically sorted list of GPS pings, followed by a Rauch-Tung- +// Striebel backward pass. Per-axis state: [position, velocity]. Constant- +// velocity dynamics with random acceleration as process noise; measurement +// model H = [1, 0] (we measure position only). // -// Why a Kalman filter here: -// Raw /getdeliverylogs pings contain jitter (multi-path in dense urban -// areas), brief stationary noise (rider parked at the drop, GPS still -// wandering), and occasional outliers (cold-start fix). The Kalman pass -// fuses each ping with the predicted trajectory from prior pings, weighted -// by their relative uncertainty. The output is a smooth polyline that -// tracks the rider's real path without the zig-zags and bunched-up dots -// near drops, and it costs O(N) — runs once per delivery on fetch. +// Pipeline: +// 1. Pre-filter teleport pings (>maxSpeedKmh between consecutive pings, +// e.g. cold-start fix, GPS multipath). These would otherwise tug the +// forward filter even with the in-loop Mahalanobis gate enabled. +// 2. Forward Kalman pass with Mahalanobis 3σ outlier gating — pings whose +// innovation exceeds the gate are not used to update; the prediction +// is kept as the posterior. Stores prior + posterior moments at each +// step so the backward pass can run. +// 3. Backward RTS smoother — refines every step using ALL future +// observations. Logs are fetched in one shot (not streamed) so we +// can afford the second pass; the accuracy lift is biggest near the +// start of the trail and through turns the forward pass under-corrects. // -// Tuning: -// processNoise (q) — random-acceleration variance (deg²/s²). Lower = a -// smoother result but slower to follow sharp turns. -// measurementNoise (r) — GPS-fix variance (deg²). Higher = trust pings -// less, lean on the predicted state more. -// The defaults below correspond loosely to ~5m GPS accuracy and gentle -// urban acceleration. Bump q if smoothing eats genuine turns; bump r if -// the line still wiggles between pings. +// Tuning (all in degrees² since pings are in lat/lng): +// processNoise (q) — random-acceleration variance (deg²/s²). Default +// tuned for urban two-wheelers (~1 m/s² accel). +// Lower = smoother but slower to follow sharp turns. +// measurementNoise (r) — GPS-fix variance (deg²). Default = ~5 m std dev, +// which matches consumer GPS in open urban areas. +// Bump for dense canyons. +// outlierGate — Mahalanobis² threshold for in-loop rejection. +// 9.0 = 3σ (≈ 99.7% of inliers pass). +// maxSpeedKmh — pre-filter for impossible inter-ping speed. +// 120 km/h covers any legal two-wheeler movement +// plus margin; anything above is GPS error. function kalmanSmoothGps(pings, options = {}) { if (!Array.isArray(pings) || pings.length === 0) return []; - if (pings.length === 1) { - return [{ lat: pings[0].lat, lng: pings[0].lng, logdate: pings[0].logdate }]; + + // 1. Filter out obviously invalid coordinate pings (e.g. 0,0 or NaN) + const cleanedPings = pings.filter(p => + Number.isFinite(p.lat) && + Number.isFinite(p.lng) && + (Math.abs(p.lat) > 0.1 || Math.abs(p.lng) > 0.1) + ); + + if (cleanedPings.length === 0) return []; + if (cleanedPings.length === 1) { + return [{ lat: cleanedPings[0].lat, lng: cleanedPings[0].lng, logdate: cleanedPings[0].logdate, _ts: cleanedPings[0]._ts }]; } const processNoise = - options.processNoise != null ? options.processNoise : 1e-9; + options.processNoise != null ? options.processNoise : 1e-10; const measurementNoise = - options.measurementNoise != null ? options.measurementNoise : 1e-7; + options.measurementNoise != null ? options.measurementNoise : 2e-9; + const outlierGate = + options.outlierGate != null ? options.outlierGate : 9.0; + const maxSpeedKmh = + options.maxSpeedKmh != null ? options.maxSpeedKmh : 120; const tsOf = (p) => p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0); - // Run a 1D Kalman over one axis (lat or lng). State: [pos, vel]; cov: 2x2. - const smoothAxis = (axisKey) => { - let x = pings[0][axisKey]; // position estimate - let v = 0; // velocity estimate - // Initial covariance — large enough that the first few measurements - // dominate over the initial state. - let p00 = 1, p01 = 0, p10 = 0, p11 = 1; - const out = [x]; - let prevTs = tsOf(pings[0]); + // 2. Scan forward to find the first valid starting anchor + let startIdx = 0; + while (startIdx < cleanedPings.length - 1) { + const p0 = cleanedPings[startIdx]; + const p1 = cleanedPings[startIdx + 1]; + const ts0 = tsOf(p0); + const ts1 = tsOf(p1) || ts0 + 1000; + const dtSec = Math.max(0.001, (ts1 - ts0) / 1000); + const km = haversineKm([p0.lat, p0.lng], [p1.lat, p1.lng]); + const speedKmh = (km / dtSec) * 3600; - for (let i = 1; i < pings.length; i++) { - const ts = tsOf(pings[i]) || prevTs + 1000; + if (speedKmh <= maxSpeedKmh) { + break; + } else { + // Speed is too high. Check if p1->p2 is normal (meaning p0 is the outlier) + if (startIdx + 2 < cleanedPings.length) { + const p2 = cleanedPings[startIdx + 2]; + const ts2 = tsOf(p2) || ts1 + 1000; + const dtSec12 = Math.max(0.001, (ts2 - ts1) / 1000); + const km12 = haversineKm([p1.lat, p1.lng], [p2.lat, p2.lng]); + const speedKmh12 = (km12 / dtSec12) * 3600; + + if (speedKmh12 <= maxSpeedKmh) { + startIdx = startIdx + 1; + continue; + } + } + startIdx++; + } + } + + // 3. Teleport filter starting from the valid anchor + const accepted = [cleanedPings[startIdx]]; + let lastTs = tsOf(cleanedPings[startIdx]); + for (let i = startIdx + 1; i < cleanedPings.length; i++) { + const p = cleanedPings[i]; + const ts = tsOf(p) || lastTs + 1000; + const dtSec = Math.max(0.001, (ts - lastTs) / 1000); + const prev = accepted[accepted.length - 1]; + const km = haversineKm([prev.lat, prev.lng], [p.lat, p.lng]); + const speedKmh = (km / dtSec) * 3600; + if (speedKmh > maxSpeedKmh) continue; + accepted.push(p); + lastTs = ts; + } + + if (accepted.length < 2) { + return accepted.map((p) => ({ lat: p.lat, lng: p.lng, logdate: p.logdate, _ts: p._ts })); + } + + // Run a 1D Kalman + RTS smoother over one axis. Returns smoothed + // positions parallel to `accepted`. + const smoothAxis = (axisKey) => { + const N = accepted.length; + // Per-step storage for the backward RTS pass. + const xPost = new Array(N); // [pos, vel] posterior after update + const pPost = new Array(N); // 2x2 cov posterior, flattened [p00,p01,p10,p11] + const xPrior = new Array(N); // predicted mean before update + const pPrior = new Array(N); // predicted cov before update + const dtArr = new Array(N); // dt from i-1 → i, for RTS transition + + // Initial state: position = first measurement, velocity from the first + // two pings (better than 0 — keeps the start of the trail from lagging + // behind the rider's actual motion). Initial position covariance = r + // (we just measured it); initial velocity covariance is loose so it + // can be refined quickly. + const ts0 = tsOf(accepted[0]); + const ts1 = tsOf(accepted[1]); + const dt01 = Math.max(0.1, (ts1 - ts0) / 1000); + const v0 = (accepted[1][axisKey] - accepted[0][axisKey]) / dt01; + xPost[0] = [accepted[0][axisKey], v0]; + pPost[0] = [measurementNoise, 0, 0, 1]; + xPrior[0] = xPost[0].slice(); + pPrior[0] = pPost[0].slice(); + dtArr[0] = 0; + + let prevTs = ts0; + for (let i = 1; i < N; i++) { + const ts = tsOf(accepted[i]) || prevTs + 1000; const dt = Math.max(0.1, (ts - prevTs) / 1000); prevTs = ts; + dtArr[i] = dt; // ─── Predict ─── // x' = F x where F = [[1, dt], [0, 1]] - const xPred = x + v * dt; - const vPred = v; + const [xPrev, vPrev] = xPost[i - 1]; + const xPredPos = xPrev + vPrev * dt; + const xPredVel = vPrev; // P' = F P F^T + Q where Q = q · [[dt⁴/4, dt³/2], [dt³/2, dt²]] + const [pp00, pp01, pp10, pp11] = pPost[i - 1]; const dt2 = dt * dt; const dt3 = dt2 * dt; const dt4 = dt3 * dt; - const np00 = p00 + dt * (p01 + p10) + dt2 * p11 + (dt4 / 4) * processNoise; - const np01 = p01 + dt * p11 + (dt3 / 2) * processNoise; - const np10 = p10 + dt * p11 + (dt3 / 2) * processNoise; - const np11 = p11 + dt2 * processNoise; + const np00 = pp00 + dt * (pp01 + pp10) + dt2 * pp11 + (dt4 / 4) * processNoise; + const np01 = pp01 + dt * pp11 + (dt3 / 2) * processNoise; + const np10 = pp10 + dt * pp11 + (dt3 / 2) * processNoise; + const np11 = pp11 + dt2 * processNoise; + xPrior[i] = [xPredPos, xPredVel]; + pPrior[i] = [np00, np01, np10, np11]; - // ─── Update ─── - // y = z − Hx' (innovation, measurement vs prediction) - const z = pings[i][axisKey]; - const y = z - xPred; + // ─── Update (with Mahalanobis gating) ─── + // y = z − Hx' (innovation) // S = H P' H^T + R (innovation covariance) + // Reject the measurement if mahal² = y²/S exceeds the gate. The + // prediction then carries forward as the posterior — the trail stays + // continuous instead of being yanked toward a bad fix. + const z = accepted[i][axisKey]; + const y = z - xPredPos; const S = np00 + measurementNoise; - // K = P' H^T / S (Kalman gain) + const mahal2 = (y * y) / S; + if (mahal2 > outlierGate) { + xPost[i] = [xPredPos, xPredVel]; + pPost[i] = [np00, np01, np10, np11]; + continue; + } + // K = P' H^T / S const K0 = np00 / S; const K1 = np10 / S; // x = x' + K y - x = xPred + K0 * y; - v = vPred + K1 * y; + const newPos = xPredPos + K0 * y; + const newVel = xPredVel + K1 * y; // P = (I − K H) P' - p00 = (1 - K0) * np00; - p01 = (1 - K0) * np01; - p10 = np10 - K1 * np00; - p11 = np11 - K1 * np01; - - out.push(x); + xPost[i] = [newPos, newVel]; + pPost[i] = [ + (1 - K0) * np00, + (1 - K0) * np01, + np10 - K1 * np00, + np11 - K1 * np01 + ]; } - return out; + + // ─── Backward RTS smoother ───────────────────────────────────────── + // x_smooth[N-1] = x_post[N-1] + // For i = N-2 … 0: + // C = P_post[i] · F^T · inv(P_prior[i+1]) + // x_smooth[i] = x_post[i] + C · (x_smooth[i+1] − x_prior[i+1]) + // F^T for a constant-velocity model is [[1,0],[dt,1]], so + // P_post · F^T = [[p00 + dt·p01, p01], + // [p10 + dt·p11, p11]] + const xSmooth = new Array(N); + xSmooth[N - 1] = xPost[N - 1].slice(); + for (let i = N - 2; i >= 0; i--) { + const dt = dtArr[i + 1]; + const [pp00, pp01, pp10, pp11] = pPost[i]; + const a = pp00 + dt * pp01; + const b = pp01; + const c = pp10 + dt * pp11; + const d = pp11; + // Invert P_prior[i+1] (2x2): inv = (1/det) · [[q11,-q01],[-q10,q00]] + const [q00, q01, q10, q11] = pPrior[i + 1]; + const det = q00 * q11 - q01 * q10; + if (!Number.isFinite(det) || Math.abs(det) < 1e-30) { + xSmooth[i] = xPost[i].slice(); + continue; + } + const inv00 = q11 / det; + const inv01 = -q01 / det; + const inv10 = -q10 / det; + const inv11 = q00 / det; + // Smoother gain C = (P_post · F^T) · inv(P_prior_next) + const c00 = a * inv00 + b * inv10; + const c01 = a * inv01 + b * inv11; + const c10 = c * inv00 + d * inv10; + const c11 = c * inv01 + d * inv11; + const dxPos = xSmooth[i + 1][0] - xPrior[i + 1][0]; + const dxVel = xSmooth[i + 1][1] - xPrior[i + 1][1]; + xSmooth[i] = [ + xPost[i][0] + c00 * dxPos + c01 * dxVel, + xPost[i][1] + c10 * dxPos + c11 * dxVel + ]; + } + + return xSmooth.map((s) => s[0]); }; const lats = smoothAxis('lat'); const lngs = smoothAxis('lng'); - return pings.map((p, i) => ({ + return accepted.map((p, i) => ({ lat: lats[i], lng: lngs[i], logdate: p.logdate, @@ -702,6 +848,17 @@ const Dispatch = ({ const [focusedZone, setFocusedZone] = useState(null); // Single delivery stop pinned by clicking its sidebar row — overrides the rider's full-route bounds on the map. const [focusedStop, setFocusedStop] = useState(null); + // Sort mode for the focused-rider trip cards in the left sidebar. + // 'planned' (default) — orders displayed in dispatched sequence (step asc). + // 'time' — orders displayed in actual completion sequence + // (deliverytime asc, expecteddeliverytime fallback, + // step tiebreaker). Mirrors how the Compare timeline + // numbers its sequenceStep so an operator can switch + // between sidebar + Compare without re-mapping + // which delivery is "1st", "2nd", etc. + // Global per the focused rider — every trip in the rider detail shares the + // mode so the toggle in any trip-header reflects (and controls) all trips. + const [tripSortMode, setTripSortMode] = useState('planned'); // Holds leaflet marker instances keyed by orderid so we can imperatively open // their popups when the user clicks a step in the focused-rider sidebar. const orderMarkerRefs = useRef({}); @@ -2271,6 +2428,12 @@ const Dispatch = ({
{o.rider_name || o.ridername || 'Unassigned'}
+ {(o.deliverycustomer || o.customername) && ( +
+ + {o.deliverycustomer || o.customername} +
+ )} {o.deliveryid != null && (
Delivery #{o.deliveryid}
)} @@ -3566,10 +3729,40 @@ const Dispatch = ({ return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s); }); const activeOrderId = activeOrder ? activeOrder.orderid : null; + + // Completion-time epoch for a delivery. Mirrors the Compare + // timeline's `tsOf` (Dispatch.js — riderActualTracks builder) + // so the sequence number on the sidebar in 'time' mode lines + // up with the Compare timeline's sequenceStep. + const completionTs = (o) => { + const t = o.deliverytime || o.expecteddeliverytime; + if (!t) return Number.MAX_SAFE_INTEGER; + const d = dayjs(t); + return d.isValid() ? d.valueOf() : Number.MAX_SAFE_INTEGER; + }; + + const isTimeMode = tripSortMode === 'time'; let prevKitchenKey = null; return Object.entries(trips) .sort(([a], [b]) => Number(a) - Number(b)) - .map(([tNum, tOrders]) => ( + .map(([tNum, tOrders]) => { + // 'planned' keeps the trip's incoming step order (the + // caller already sorted by trip_number, step). 'time' + // re-sorts inside each trip by completion time; step + // is the tiebreaker so two deliveries logged in the + // same minute still render in dispatched order. + // Kitchen-transition badges are computed off the displayed + // order so the "Switch to X" rows stay accurate after + // reordering — without this they'd reference the + // original sequence and confuse the operator. + const displayOrders = isTimeMode + ? [...tOrders].sort((a, b) => { + const diff = completionTs(a) - completionTs(b); + if (diff !== 0) return diff; + return (a.step || 0) - (b.step || 0); + }) + : tOrders; + return (
Trip {tNum} @@ -3577,9 +3770,44 @@ const Dispatch = ({ {tOrders.length} stops {tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km + {/* iOS-style segmented control. Track sits in a soft + inset bg; the active item is a clean white "thumb" + with a subtle shadow. We deliberately don't fill + the active pill with the rider's color — the + `Trip N` badge to the left already carries that + identity, and doubling the accent makes the row + read as competing pills instead of a controlled + hierarchy. The CSS owns all interactive states. */} +
+ + +
- {tOrders.map((o, idx) => { + {displayOrders.map((o, idx) => { const kitchenKey = (o.kitchen_key || o.pickupcustomer || 'Unknown').toLowerCase().trim(); const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey; prevKitchenKey = kitchenKey; @@ -3592,6 +3820,20 @@ const Dispatch = ({ const profit = parseFloat(o.profit || 0); const isLoss = profit < 0; const estMeters = calculateEstMeters(focusedRider.id, o); + // Badge number: + // planned mode → planned step (o.step), so a + // reordered list still surfaces the dispatched + // step number on each card. + // time mode → completion sequence inside the + // trip (1 = delivered first). Undelivered rows + // sort to the end so their idx+1 is the highest. + const displayNum = isTimeMode ? idx + 1 : (o.step || idx + 1); + // "Not yet delivered" indicator for time mode — we + // pushed these rows to the end via MAX_SAFE_INTEGER + // sort key, but a visual cue makes that obvious + // without forcing the operator to read the status pill. + const isUndeliveredInTimeMode = + isTimeMode && !o.deliverytime; return ( @@ -3599,7 +3841,7 @@ const Dispatch = ({
Switch to {o.pickupcustomer}
)}
setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined} @@ -3612,7 +3854,12 @@ const Dispatch = ({ title={canFocus ? (isStopActive ? 'Click to show full trip' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined} >
-
{o.step || idx + 1}
+
+ {displayNum} +
Order #{o.orderid}
@@ -3716,7 +3963,8 @@ const Dispatch = ({ })}
- )); + ); + }); })()} ) : ( @@ -4562,33 +4810,34 @@ const Dispatch = ({ eventHandlers={ orderForTrack ? { - // Match the planned-route marker UX: hover surfaces - // the rich order card in the centered overlay. The - // ~200ms grace timer on mouseout lets the cursor - // travel onto the overlay without flicker. Pinning - // is implicit while focusedCompareStep === this - // step, so the card stays put while the user is - // inspecting this delivery. - mouseover: () => { + // Click-only: hovering the numbered pin does + // nothing. Earlier the modal opened on mouseover, + // which made the rich order card flash open while + // panning the map and blocked the operator from + // reading other steps in the trail. Click toggles + // the modal — same `pinnedPopupsRef` pattern the + // planned-route marker uses, so the modal stays + // pinned (doesn't auto-close on mouse leave) until + // the user clicks the pin again, the × button, or + // the map background. + click: (e) => { + if (e.originalEvent) e.originalEvent.stopPropagation(); + setFocusedCompareStep((prev) => + prev === t.sequenceStep ? null : t.sequenceStep + ); if (popupHoverTimerRef.current) { clearTimeout(popupHoverTimerRef.current); popupHoverTimerRef.current = null; } - setCenterPopupOrder(orderForTrack); - }, - mouseout: () => { - if (focusedCompareStep === t.sequenceStep) return; - if (popupHoverTimerRef.current) { - clearTimeout(popupHoverTimerRef.current); + const id = String(orderForTrack.orderid); + if (pinnedPopupsRef.current.has(id)) { + pinnedPopupsRef.current.delete(id); + setCenterPopupOrder(null); + } else { + pinnedPopupsRef.current.add(id); + setCenterPopupOrder(orderForTrack); } - popupHoverTimerRef.current = setTimeout(() => { - setCenterPopupOrder((cur) => - cur && String(cur.orderid) === String(orderForTrack.orderid) ? null : cur - ); - popupHoverTimerRef.current = null; - }, 200); - }, - click: handleEndMarkerClick + } } : { click: handleEndMarkerClick } } diff --git a/src/pages/nearle/reports/mapWithRoute.js b/src/pages/nearle/reports/mapWithRoute.js index e6a42ae..251e5dd 100644 --- a/src/pages/nearle/reports/mapWithRoute.js +++ b/src/pages/nearle/reports/mapWithRoute.js @@ -60,26 +60,57 @@ const MapWithRoute = ({ coordinates, additionalProps, order, setMapOpen }) => { return; } - const start = coordinates[0]; - const end = coordinates[coordinates.length - 1]; - - const url = `https://router.project-osrm.org/route/v1/driving/${start.lng},${start.lat};${end.lng},${end.lat}?overview=full&geometries=geojson`; + const subsample = (arr, max) => { + if (arr.length <= max) return arr; + const step = Math.ceil(arr.length / max); + const out = arr.filter((_, i) => i % step === 0); + const last = arr[arr.length - 1]; + if (out[out.length - 1] !== last) out.push(last); + return out; + }; + // Attempt 1 — map-matching (best fidelity for dense traces). try { - const res = await fetch(url); - const data = await res.json(); + const ptsM = subsample(coordinates, 90); + const coordsM = ptsM.map((p) => `${p.lng},${p.lat}`).join(';'); + const matchUrl = `https://router.project-osrm.org/match/v1/driving/${coordsM}?overview=full&geometries=geojson&gaps=ignore&tidy=true`; + + const resM = await fetch(matchUrl); + const jsonM = await resM.json(); + if (jsonM.matchings && jsonM.matchings.length > 0) { + const poly = jsonM.matchings.flatMap((m) => + (m.geometry?.coordinates || []).map(([lng, lat]) => ({ lat, lng })) + ); + if (poly.length >= 2) { + setRoutePoints(poly); + setLoading(false); + return; + } + } + } catch (e) { + console.warn('OSRM Match error, trying route fallback:', e); + } - if (data.routes?.length) { - const points = data.routes[0].geometry.coordinates.map(([lng, lat]) => ({ - lat, - lng - })); - setRoutePoints(points); + // Attempt 2 — waypoint routing through a coarser subsample. + try { + const ptsR = subsample(coordinates, 25); + const coordsR = ptsR.map((p) => `${p.lng},${p.lat}`).join(';'); + const routeUrl = `https://router.project-osrm.org/route/v1/driving/${coordsR}?overview=full&geometries=geojson`; + + const resR = await fetch(routeUrl); + const jsonR = await resR.json(); + if (jsonR.routes && jsonR.routes[0]) { + const poly = jsonR.routes[0].geometry.coordinates.map(([lng, lat]) => ({ lat, lng })); + setRoutePoints(poly); + } else { + // Fallback to drawing direct lines between coordinates + setRoutePoints(coordinates); } } catch (err) { - console.error('OSRM Error:', err); + console.error('OSRM Route fallback error:', err); + setRoutePoints(coordinates); } finally { - setLoading(false); // ALWAYS STOP LOADING + setLoading(false); } }; diff --git a/src/pages/nearle/reports/ordersDetails.js b/src/pages/nearle/reports/ordersDetails.js index 1337199..d83fead 100644 --- a/src/pages/nearle/reports/ordersDetails.js +++ b/src/pages/nearle/reports/ordersDetails.js @@ -60,7 +60,7 @@ import { FaCircleCheck } from 'react-icons/fa6'; import MapWithRoute from './mapWithRoute'; import CircularLoader from 'components/CircularLoader'; -import { fetchDeliveries, getriderbydelivery, gettenantlocations, getTenants } from 'pages/api/api'; +import { fetchDeliveries, fetchRidersList, gettenantlocations, getTenants } from 'pages/api/api'; import { CSVExport } from 'components/third-party/ReactTable'; import Loader from 'components/Loader'; import { enqueueSnackbar } from 'notistack'; @@ -218,6 +218,205 @@ const StampCell = ({ value, formatDate, formatTime, success }) => { // ==============================|| Orders Details ||============================== // +// Haversine distance between two [lat, lng] points in kilometers. +function haversineKm(a, b) { + const R = 6371; // km + const toRad = (d) => (d * Math.PI) / 180; + const lat1 = toRad(a[0]); + const lat2 = toRad(b[0]); + const dLat = toRad(b[0] - a[0]); + const dLon = toRad(b[1] - a[1]); + const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.min(1, Math.sqrt(s))); +} + +function kalmanSmoothGps(pings, options = {}) { + if (!Array.isArray(pings) || pings.length === 0) return []; + + // 1. Filter out obviously invalid coordinate pings (e.g. 0,0 or NaN) + const cleanedPings = pings.filter(p => + Number.isFinite(p.lat) && + Number.isFinite(p.lng) && + (Math.abs(p.lat) > 0.1 || Math.abs(p.lng) > 0.1) + ); + + if (cleanedPings.length === 0) return []; + if (cleanedPings.length === 1) { + return [{ lat: cleanedPings[0].lat, lng: cleanedPings[0].lng, logdate: cleanedPings[0].logdate, _ts: cleanedPings[0]._ts }]; + } + + const processNoise = + options.processNoise != null ? options.processNoise : 1e-10; + const measurementNoise = + options.measurementNoise != null ? options.measurementNoise : 2e-9; + const outlierGate = + options.outlierGate != null ? options.outlierGate : 9.0; + const maxSpeedKmh = + options.maxSpeedKmh != null ? options.maxSpeedKmh : 120; + + const tsOf = (p) => + p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0); + + // 2. Scan forward to find the first valid starting anchor + let startIdx = 0; + while (startIdx < cleanedPings.length - 1) { + const p0 = cleanedPings[startIdx]; + const p1 = cleanedPings[startIdx + 1]; + const ts0 = tsOf(p0); + const ts1 = tsOf(p1) || ts0 + 1000; + const dtSec = Math.max(0.001, (ts1 - ts0) / 1000); + const km = haversineKm([p0.lat, p0.lng], [p1.lat, p1.lng]); + const speedKmh = (km / dtSec) * 3600; + + if (speedKmh <= maxSpeedKmh) { + break; + } else { + // Speed is too high. Check if p1->p2 is normal (meaning p0 is the outlier) + if (startIdx + 2 < cleanedPings.length) { + const p2 = cleanedPings[startIdx + 2]; + const ts2 = tsOf(p2) || ts1 + 1000; + const dtSec12 = Math.max(0.001, (ts2 - ts1) / 1000); + const km12 = haversineKm([p1.lat, p1.lng], [p2.lat, p2.lng]); + const speedKmh12 = (km12 / dtSec12) * 3600; + + if (speedKmh12 <= maxSpeedKmh) { + startIdx = startIdx + 1; + continue; + } + } + startIdx++; + } + } + + // 3. Teleport filter starting from the valid anchor + const accepted = [cleanedPings[startIdx]]; + let lastTs = tsOf(cleanedPings[startIdx]); + for (let i = startIdx + 1; i < cleanedPings.length; i++) { + const p = cleanedPings[i]; + const ts = tsOf(p) || lastTs + 1000; + const dtSec = Math.max(0.001, (ts - lastTs) / 1000); + const prev = accepted[accepted.length - 1]; + const km = haversineKm([prev.lat, prev.lng], [p.lat, p.lng]); + const speedKmh = (km / dtSec) * 3600; + if (speedKmh > maxSpeedKmh) continue; + accepted.push(p); + lastTs = ts; + } + + if (accepted.length < 2) { + return accepted.map((p) => ({ lat: p.lat, lng: p.lng, logdate: p.logdate, _ts: p._ts })); + } + + // Run a 1D Kalman + RTS smoother over one axis. Returns smoothed + // positions parallel to `accepted`. + const smoothAxis = (axisKey) => { + const N = accepted.length; + const xPost = new Array(N); + const pPost = new Array(N); + const xPrior = new Array(N); + const pPrior = new Array(N); + const dtArr = new Array(N); + + const ts0 = tsOf(accepted[0]); + const ts1 = tsOf(accepted[1]); + const dt01 = Math.max(0.1, (ts1 - ts0) / 1000); + const v0 = (accepted[1][axisKey] - accepted[0][axisKey]) / dt01; + xPost[0] = [accepted[0][axisKey], v0]; + pPost[0] = [measurementNoise, 0, 0, 1]; + xPrior[0] = xPost[0].slice(); + pPrior[0] = pPost[0].slice(); + dtArr[0] = 0; + + let prevTs = ts0; + for (let i = 1; i < N; i++) { + const ts = tsOf(accepted[i]) || prevTs + 1000; + const dt = Math.max(0.1, (ts - prevTs) / 1000); + prevTs = ts; + dtArr[i] = dt; + + // Predict + const [xPrev, vPrev] = xPost[i - 1]; + const xPredPos = xPrev + vPrev * dt; + const xPredVel = vPrev; + const [pp00, pp01, pp10, pp11] = pPost[i - 1]; + const dt2 = dt * dt; + const dt3 = dt2 * dt; + const dt4 = dt3 * dt; + const np00 = pp00 + dt * (pp01 + pp10) + dt2 * pp11 + (dt4 / 4) * processNoise; + const np01 = pp01 + dt * pp11 + (dt3 / 2) * processNoise; + const np10 = pp10 + dt * pp11 + (dt3 / 2) * processNoise; + const np11 = pp11 + dt2 * processNoise; + xPrior[i] = [xPredPos, xPredVel]; + pPrior[i] = [np00, np01, np10, np11]; + + // Update + const z = accepted[i][axisKey]; + const y = z - xPredPos; + const S = np00 + measurementNoise; + const mahal2 = (y * y) / S; + if (mahal2 > outlierGate) { + xPost[i] = [xPredPos, xPredVel]; + pPost[i] = [np00, np01, np10, np11]; + continue; + } + const K0 = np00 / S; + const K1 = np10 / S; + const newPos = xPredPos + K0 * y; + const newVel = xPredVel + K1 * y; + xPost[i] = [newPos, newVel]; + pPost[i] = [ + (1 - K0) * np00, + (1 - K0) * np01, + np10 - K1 * np00, + np11 - K1 * np01 + ]; + } + + // RTS backward smoother + const xSmooth = new Array(N); + xSmooth[N - 1] = xPost[N - 1].slice(); + for (let i = N - 2; i >= 0; i--) { + const dt = dtArr[i + 1]; + const [pp00, pp01, pp10, pp11] = pPost[i]; + const a = pp00 + dt * pp01; + const b = pp01; + const c = pp10 + dt * pp11; + const d = pp11; + const [q00, q01, q10, q11] = pPrior[i + 1]; + const det = q00 * q11 - q01 * q10; + if (!Number.isFinite(det) || Math.abs(det) < 1e-30) { + xSmooth[i] = xPost[i].slice(); + continue; + } + const inv00 = q11 / det; + const inv01 = -q01 / det; + const inv10 = -q10 / det; + const inv11 = q00 / det; + const c00 = a * inv00 + b * inv10; + const c01 = a * inv01 + b * inv11; + const c10 = c * inv00 + d * inv10; + const c11 = c * inv01 + d * inv11; + const dxPos = xSmooth[i + 1][0] - xPrior[i + 1][0]; + const dxVel = xSmooth[i + 1][1] - xPrior[i + 1][1]; + xSmooth[i] = [ + xPost[i][0] + c00 * dxPos + c01 * dxVel, + xPost[i][1] + c10 * dxPos + c11 * dxVel + ]; + } + + return xSmooth.map((s) => s[0]); + }; + + const lats = smoothAxis('lat'); + const lngs = smoothAxis('lng'); + return accepted.map((p, i) => ({ + lat: lats[i], + lng: lngs[i], + logdate: p.logdate, + _ts: p._ts + })); +} + export default function OrdersDetails() { const loadMoreRef = useRef(); const containerRef = useRef(); @@ -319,14 +518,35 @@ export default function OrdersDetails() { try { const res = await axios.get(`${process.env.REACT_APP_URL3}/deliveries/getdeliverylogs/?deliveryid=${id}`); const datas = res.data.details; - if (datas.length != 0) { - setRiderStart(datas[0].logdate); - setRiderEnd(datas[datas.length - 1].logdate); - const coData = datas.map((data) => ({ lat: data.latitude, lng: data.longitude })); - setRiderCoordinates(coData); - calculateTotalDistance(coData); - setMapOpen(true); - } else if (datas == null || !datas) { + if (Array.isArray(datas) && datas.length !== 0) { + // Sort chronologically by logdate + const sorted = datas + .map((r) => { + const ts = r?.logdate ? dayjs(r.logdate) : null; + return { + lat: parseFloat(r?.latitude ?? r?.lat), + lng: parseFloat(r?.longitude ?? r?.lng ?? r?.lon), + logdate: r?.logdate, + _ts: ts && ts.isValid() ? ts.valueOf() : Number.MAX_SAFE_INTEGER + }; + }) + .filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng)) + .sort((a, b) => a._ts - b._ts); + + if (sorted.length !== 0) { + setRiderStart(sorted[0].logdate); + setRiderEnd(sorted[sorted.length - 1].logdate); + + // Apply Kalman filter + const smoothed = kalmanSmoothGps(sorted); + const coData = smoothed.map((data) => ({ lat: data.lat, lng: data.lng })); + setRiderCoordinates(coData); + calculateTotalDistance(coData); + setMapOpen(true); + } else { + opentoast('No Valid Logs Found', 'error', 2000); + } + } else { opentoast('No Logs Found ', 'error', 2000); } } catch (error) { @@ -416,9 +636,9 @@ export default function OrdersDetails() { isError: getriderbydeliveryIsError, error: getriderbydeliveryError } = useQuery({ - queryKey: ['getriderbydelivery', startdate, enddate, appId, tenantid, locationid], - queryFn: () => getriderbydelivery(startdate, enddate, appId, tenantid, locationid), - enabled: appId != 0 + queryKey: ['fetchRidersList', appId], + queryFn: fetchRidersList, + enabled: appId !== 0 }); const {