diff --git a/package-lock.json b/package-lock.json index 981e904..6f72698 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1841,7 +1840,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2689,8 +2687,7 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/lightningcss": { "version": "1.32.0", @@ -3227,7 +3224,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3254,7 +3250,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -3361,7 +3356,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3371,7 +3365,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4268,7 +4261,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/src/components/DispatchView.css b/src/components/DispatchView.css index 6c46e7d..edbd91a 100644 --- a/src/components/DispatchView.css +++ b/src/components/DispatchView.css @@ -10920,4 +10920,144 @@ .dispatch-container .logo-city:hover { background: rgba(88, 28, 135, 0.14); border-color: rgba(88, 28, 135, 0.45); +} + +/* ───────────────────────────────────────────────────────────────────── + RIDER TELEMETRY PANEL + Real-time GPS, signal, battery, and performance metrics + ───────────────────────────────────────────────────────────────────── */ + +.rider-telemetry-panel { + margin: 12px 0; + padding: 12px; + border-radius: 8px; + background: rgba(88, 28, 135, 0.04); + border: 1px solid rgba(88, 28, 135, 0.15); +} + +.rider-telemetry-panel .rtm-header { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #581c87; + margin-bottom: 8px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.rider-telemetry-panel .rtm-rider-name { + font-size: 11px; + font-weight: 600; + color: #64748b; + text-transform: none; + letter-spacing: normal; +} + +.rider-telemetry-panel .rtm-loading, +.rider-telemetry-panel .rtm-empty { + font-size: 12px; + color: #94a3b8; + padding: 12px; + text-align: center; +} + +.rider-telemetry-panel .rtm-grid { + display: grid; + grid-template-columns: 1fr; + gap: 8px; +} + +.rider-telemetry-panel .rtm-item { + display: flex; + gap: 8px; + padding: 8px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.5); + border: 1px solid rgba(88, 28, 135, 0.1); +} + +.rider-telemetry-panel .rtm-icon { + flex-shrink: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: #581c87; + opacity: 0.7; +} + +.rider-telemetry-panel .rtm-content { + flex: 1; + min-width: 0; +} + +.rider-telemetry-panel .rtm-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; + margin-bottom: 2px; +} + +.rider-telemetry-panel .rtm-value { + font-size: 13px; + font-weight: 600; + color: #1e293b; + word-break: break-all; + display: flex; + align-items: center; + gap: 4px; +} + +.rider-telemetry-panel .rtm-link { + font-size: 11px; + color: #3b82f6; + text-decoration: none; + margin-top: 2px; + display: inline-block; +} + +.rider-telemetry-panel .rtm-link:hover { + text-decoration: underline; +} + +.rider-telemetry-panel .rtm-bars { + display: inline-flex; + gap: 1px; + align-items: flex-end; + height: 16px; +} + +.rider-telemetry-panel .rtm-bars .bar { + width: 3px; + border-radius: 1px; + opacity: 0.3; + transition: opacity 0.2s; +} + +.rider-telemetry-panel .rtm-bars .bar.active { + opacity: 1; +} + +.rider-telemetry-panel .rtm-dbm { + font-size: 11px; + color: #64748b; + margin-left: 4px; +} + +.rider-telemetry-panel .rtm-battery-bar { + width: 100%; + height: 4px; + border-radius: 2px; + background: #e2e8f0; + overflow: hidden; + margin-bottom: 2px; +} + +.rider-telemetry-panel .rtm-battery-fill { + height: 100%; + transition: width 0.3s, background 0.3s; } \ No newline at end of file diff --git a/src/components/DispatchView.tsx b/src/components/DispatchView.tsx index 7025d8f..c367aa0 100644 --- a/src/components/DispatchView.tsx +++ b/src/components/DispatchView.tsx @@ -4,19 +4,17 @@ */ /** - * Dispatch page — a faithful port of the operations console's dispatch cockpit - * (nearle_console/dispatch). It reuses that page's actual stylesheet verbatim - * (`./DispatchView.css`, copied from Dispatch.css) and reproduces the same DOM / - * class structure: the `#hdr` bar, `#strat-row` view tabs, the 400px `#sidebar` - * (RIDER DISPATCH header + KPI tiles + rider/zone cards + per-trip order cards), - * and the `#map-wrap` centrepiece. + * Dispatch cockpit — integrates live deliveries, rider assignments, and route + * visualization. Reuses the dispatch page stylesheet from the operations console + * and reproduces its DOM structure: header, view-mode tabs, sidebar (KPI + group + * cards), and map centrepiece with Leaflet. * - * The source map is a Leaflet canvas of planned-vs-actual rider routes (OSRM - * road-snapping, Kalman-smoothed GPS) plus AI rider-assignment posting to - * external optimisation services. Those need a mapping stack + dispatch backends - * this tenant doesn't expose, so the `#map-wrap` plots the real planned stop - * order and marks the live-GPS / compare / AI-assign layers as awaiting backend — - * no fabricated telemetry. Everything else is driven by the live Fiesta feed. + * Features: + * • Group deliveries by rider, zone, location, or status + * • Focus on a specific group to see its trip blocks and detailed order cards + * • Map-based route visualization with planned stops (actual GPS awaiting backend) + * • Real-time KPI cards (orders, riders, completion %) + * • Date navigation for historical dispatch view */ import React, { useMemo, useState } from 'react'; @@ -41,12 +39,23 @@ import { List, Play, } from 'lucide-react'; -import { useFiestaDeliveries, useFiestaRiders } from '../services/fiestaQueries'; +import { + useFiestaDeliveries, + useFiestaRiders, + useFiestaRiderPeriodicLogs, +} from '../services/fiestaQueries'; import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi'; +import { + colorFor, + getStatusStyle, + STATUS_STYLES, + extractTimeOnly, +} from '../services/dispatchShared'; import DispatchMap, { type MapPoint } from './DispatchMap'; +import RiderTelemetryPanel from './RiderTelemetryPanel'; import './DispatchView.css'; -// ── Status colours (match the console palette) ─────────────────────────────────── +// Legacy direct utilities (will be migrated to dispatchShared) const STATUS_HEX: Record = { pending: '#f59e0b', accepted: '#6366f1', @@ -57,19 +66,12 @@ const STATUS_HEX: Record = { delivered: '#22c55e', cancelled: '#ef4444', }; + function statusStyle(s: string): React.CSSProperties { const hex = STATUS_HEX[s.toLowerCase()] || '#64748b'; return { background: `${hex}1f`, color: hex }; } -// Stable rider/zone colour. -const COLORS = ['#3b82f6', '#a855f7', '#10b981', '#f59e0b', '#ef4444', '#6366f1', '#14b8a6', '#ec4899', '#f97316', '#06b6d4']; -function colorFor(key: string): string { - let hash = 0; - for (let i = 0; i < key.length; i++) hash = key.charCodeAt(i) + ((hash << 5) - hash); - return COLORS[Math.abs(hash) % COLORS.length]; -} - /** Drop coordinates from a delivery row (several field spellings), or null. */ function dropLatLon(r: Row): [number, number] | null { const lat = fnum(r.droplat) || fnum(r.deliverylat) || fnum(r.deliverylatitude); @@ -120,14 +122,24 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID } const [date, setDate] = useState(ymd(today)); const [viewMode, setViewMode] = useState('riders'); const [focusedId, setFocusedId] = useState(null); + const [focusedRiderId, setFocusedRiderId] = useState(null); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [tripSort, setTripSort] = useState<'planned' | 'time'>('planned'); const [animateNonce, setAnimateNonce] = useState(0); const [animating, setAnimating] = useState(false); + // Core dispatch data const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid }); const ridersQ = useFiestaRiders({ tenantid: tenantId }); + // Rider periodic logs (GPS snapshots) for the focused rider + const riderLogsQ = useFiestaRiderPeriodicLogs({ + userid: focusedRiderId ?? undefined, + fromdate: date, + todate: date, + tenantid: tenantId, + }); + // Live deliveries only — no sample/demo fallback. When the feed is empty the // cockpit shows a genuine empty state rather than fabricated riders/stops. const allRows = deliveriesQ.data ?? []; @@ -410,6 +422,8 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID } setTripSort={setTripSort} onBack={() => setFocusedId(null)} fmtTime={fmtTime} + riderLogs={riderLogsQ.data} + riderLogsLoading={riderLogsQ.isLoading} /> ) : groups.length === 0 ? (
No deliveries for this day
@@ -421,8 +435,19 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID } {groups.map((g) => ( {viewMode === 'riders' - ? setFocusedId(g.id)} /> - : setFocusedId(g.id)} />} + ? { + setFocusedId(g.id); + // Extract rider ID from first order in group for GPS logs + const rid = fnum(g.orders[0]?.userid); + if (rid) setFocusedRiderId(rid); + }} + /> + : { + setFocusedId(g.id); + setFocusedRiderId(null); + }} />} ))} @@ -575,6 +600,8 @@ function FocusedDetail({ setTripSort, onBack, fmtTime, + riderLogs, + riderLogsLoading, }: { focused: Group; tripBlocks: Array<{ label: string; color: string; orders: Row[] }>; @@ -583,6 +610,8 @@ function FocusedDetail({ setTripSort: (v: 'planned' | 'time') => void; onBack: () => void; fmtTime: (raw: unknown) => string; + riderLogs?: Row[]; + riderLogsLoading?: boolean; }) { return ( <> @@ -590,6 +619,14 @@ function FocusedDetail({ Back to list + {riderLogs && riderLogs.length > 0 && ( + + )} + {tripBlocks.map((blk, bi) => (
diff --git a/src/components/RiderTelemetryPanel.tsx b/src/components/RiderTelemetryPanel.tsx new file mode 100644 index 0000000..424fe19 --- /dev/null +++ b/src/components/RiderTelemetryPanel.tsx @@ -0,0 +1,189 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Rider telemetry panel — displays GPS location, signal strength, battery, + * and other real-time metrics for the selected rider. Integrated into the + * dispatch sidebar when a rider card is focused. + */ + +import React from 'react'; +import { MapPin, Zap, Signal, Clock, Gauge } from 'lucide-react'; +import { type Row } from '../services/fiestaApi'; + +interface RiderTelemetryPanelProps { + logs: Row[]; + riderName?: string; + isLoading?: boolean; +} + +export default function RiderTelemetryPanel({ logs, riderName, isLoading }: RiderTelemetryPanelProps) { + if (isLoading) { + return ( +
+
Telemetry
+
Loading GPS data…
+
+ ); + } + + if (!logs || logs.length === 0) { + return ( +
+
Telemetry
+
No GPS telemetry available
+
+ ); + } + + // Get the latest (most recent) GPS log + const latest = logs[logs.length - 1]; + const lat = latest?.latitude || latest?.lat; + const lon = latest?.longitude || latest?.lng; + const battery = latest?.batterypercentage || latest?.battery; + const signal = latest?.signalstrength || latest?.signal; + const speed = latest?.speed || 0; + const timestamp = latest?.logdate || latest?.timestamp; + + const getSignalBars = (signal: number | null) => { + if (!signal) return 0; + const s = Number(signal); + if (s <= -120) return 1; + if (s <= -100) return 2; + if (s <= -80) return 3; + if (s <= -60) return 4; + return 5; + }; + + const bars = getSignalBars(signal ? Number(signal) : null); + const batteryPercent = battery ? Number(battery) : null; + + return ( +
+
+ Telemetry + {riderName && {riderName}} +
+ +
+ {/* GPS Location */} + {lat && lon && ( +
+
+ +
+
+
Location
+
{Number(lat).toFixed(4)}, {Number(lon).toFixed(4)}
+ + View on Maps + +
+
+ )} + + {/* Signal Strength */} + {signal && ( +
+
+ +
+
+
Signal
+
+ + {[...Array(5)].map((_, i) => ( + + ))} + + {signal} dBm +
+
+
+ )} + + {/* Battery */} + {batteryPercent !== null && ( +
+
+ +
+
+
Battery
+
+
+
+
+ {Math.round(batteryPercent)}% +
+
+
+ )} + + {/* Speed */} + {speed && ( +
+
+ +
+
+
Speed
+
{Number(speed).toFixed(1)} km/h
+
+
+ )} + + {/* Last Update */} + {timestamp && ( +
+
+ +
+
+
Last Update
+
{formatTimeAgo(timestamp)}
+
+
+ )} +
+
+ ); +} + +function formatTimeAgo(timestamp: unknown): string { + if (!timestamp) return 'N/A'; + const ts = new Date(String(timestamp)).getTime(); + const now = Date.now(); + const diffMs = now - ts; + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + + if (diffSecs < 60) return `${diffSecs}s ago`; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + return new Date(String(timestamp)).toLocaleString(); +} diff --git a/src/services/dispatchShared.ts b/src/services/dispatchShared.ts new file mode 100644 index 0000000..51351bc --- /dev/null +++ b/src/services/dispatchShared.ts @@ -0,0 +1,195 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Shared constants and pure helpers for dispatch operations. + * Reusable across DispatchView and child components. + */ + +// ── Status Palette ──────────────────────────────────────────────────────────── +export const STATUS_STYLES = { + created: { label: 'Created', bg: '#3b82f6', fg: '#fff' }, + pending: { label: 'Pending', bg: '#f59e0b', fg: '#fff' }, + accepted: { label: 'Accepted', bg: '#8b5cf6', fg: '#fff' }, + arrived: { label: 'Arrived', bg: '#ea580c', fg: '#fff' }, + picked: { label: 'Picked', bg: '#0ea5e9', fg: '#fff' }, + active: { label: 'Active', bg: '#0ea5e9', fg: '#fff' }, + delivered: { label: 'Delivered', bg: '#22c55e', fg: '#fff' }, + skipped: { label: 'Skipped', bg: '#94a3b8', fg: '#fff' }, + cancelled: { label: 'Cancelled', bg: '#ef4444', fg: '#fff' }, +}; + +export interface StatusStyle { + label: string; + bg: string; + fg: string; +} + +export function getStatusStyle(status: string | unknown): StatusStyle { + const key = String(status || '').toLowerCase() as keyof typeof STATUS_STYLES; + return STATUS_STYLES[key] || { + label: String(status || 'Unknown'), + bg: '#64748b', + fg: '#fff', + }; +} + +// ── Order Status Sets ──────────────────────────────────────────────────────── +export const FINAL_STATUSES = new Set(['delivered']); +export const SKIPPED_STATUSES = new Set(['cancelled', 'skipped']); + +// ── Step-wise Color Palette (for >10 stops) ────────────────────────────────── +export const STEP_PALETTE = [ + '#2563eb', // blue-600 + '#dc2626', // red-600 + '#16a34a', // green-600 + '#ea580c', // orange-600 + '#9333ea', // purple-600 + '#0891b2', // cyan-600 + '#ca8a04', // yellow-600 + '#db2777', // pink-600 + '#0f766e', // teal-700 + '#7c3aed', // violet-600 + '#65a30d', // lime-600 + '#0284c7', // sky-600 + '#b91c1c', // red-700 + '#15803d', // green-700 + '#a16207', // yellow-700 + '#86198f', // fuchsia-800 +]; + +export function stepColor(index: number): string { + return STEP_PALETTE[((index % STEP_PALETTE.length) + STEP_PALETTE.length) % STEP_PALETTE.length]; +} + +// ── Rider/Zone Color Palette ────────────────────────────────────────────────── +const RIDER_COLORS = [ + '#3b82f6', '#a855f7', '#10b981', '#f59e0b', + '#ef4444', '#6366f1', '#14b8a6', '#ec4899', + '#f97316', '#06b6d4', +]; + +export function colorFor(key: string): string { + let hash = 0; + for (let i = 0; i < key.length; i++) { + hash = key.charCodeAt(i) + ((hash << 5) - hash); + } + return RIDER_COLORS[Math.abs(hash) % RIDER_COLORS.length]; +} + +// ── Delivery Status Checkers ────────────────────────────────────────────────── +export function isActiveDelivery(order: Record): boolean { + const status = String(order?.orderstatus || '').toLowerCase(); + return !FINAL_STATUSES.has(status) && !SKIPPED_STATUSES.has(status); +} + +export function getActiveOrder(orders: Record[]): Record | null { + if (!Array.isArray(orders) || !orders.length) return null; + const sorted = [...orders].sort((a, b) => { + const tA = Number(a.trip_number) || 1; + const tB = Number(b.trip_number) || 1; + if (tA !== tB) return tA - tB; + return (Number(a.step) || 0) - (Number(b.step) || 0); + }); + return sorted.find(isActiveDelivery) || null; +} + +// ── Time Batch Helpers ──────────────────────────────────────────────────────── +export interface TimeBatch { + id: string; + name?: string; + label: string; + range: string; + startHour: number; + endHour: number; +} + +const BATCHES_DEFAULT_RAW = [ + { id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 }, + { id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12.5 }, + { id: 'evening', name: 'Evening Batch', startHour: 16, endHour: 19 }, +]; + +export function formatHourLabel(h: number): string { + const wholeHour = Math.floor(h); + const minutes = Math.round((h - wholeHour) * 60); + const hr = ((wholeHour + 11) % 12) + 1; + const ampm = wholeHour >= 12 && wholeHour < 24 ? 'PM' : 'AM'; + if (minutes === 0) return `${hr} ${ampm}`; + const mm = String(minutes).padStart(2, '0'); + return `${hr}:${mm} ${ampm}`; +} + +export function formatSlotLabel(idx: number, startHour: number): string { + return `Slot ${idx + 1} · ${formatHourLabel(startHour)}`; +} + +export function formatSlotRange(startHour: number, endHour: number): string { + if (endHour >= 24) return `After ${formatHourLabel(startHour)}`; + return `${formatHourLabel(startHour)}–${formatHourLabel(endHour)}`; +} + +export function getDefaultBatches(): TimeBatch[] { + return BATCHES_DEFAULT_RAW.map((s, i) => ({ + ...s, + label: s.name || formatSlotLabel(i, s.startHour), + range: formatSlotRange(s.startHour, s.endHour), + })); +} + +export function getBatchForHour(h: number, batches: TimeBatch[]): string | null { + for (const b of batches) { + if (h >= b.startHour && h < b.endHour) return b.id; + } + return null; +} + +// ── Ordinal Numbers ─────────────────────────────────────────────────────────── +export function ordinal(n: number | null | undefined): string { + if (n == null) return ''; + const s = ['th', 'st', 'nd', 'rd']; + const v = n % 100; + return n + (s[(v - 20) % 10] || s[v] || s[0]); +} + +// ── Distance Calculations ───────────────────────────────────────────────────── +export function haversineKm( + a: [number, number], + b: [number, number], +): number { + const R = 6371; // Earth radius in km + const toRad = (d: number) => (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))); +} + +export function polylineLengthKm(points: Array<[number, number]>): number { + if (!Array.isArray(points) || points.length < 2) return 0; + let total = 0; + for (let i = 1; i < points.length; i++) { + total += haversineKm(points[i - 1], points[i]); + } + return total; +} + +// ── Scooter Icon SVG ────────────────────────────────────────────────────────── +export const SCOOTER_SVG_PATH = + 'M19,17A2,2 0 0,1 17,19A2,2 0 0,1 15,17A2,2 0 0,1 17,15A2,2 0 0,1 19,17M7,17A2,2 0 0,1 5,19A2,2 0 0,1 3,17A2,2 0 0,1 5,15A2,2 0 0,1 7,17M21.43,11.33L19.43,6.33C19.23,5.84 18.75,5.5 18.21,5.5H15V3H11V9H15.6L16.96,12.44C15.82,12.87 15,13.97 15,15.25V16H13.68C13.23,14.82 12.1,14 10.75,14C9.4,14 8.27,14.82 7.82,16H6.18C5.73,14.82 4.6,14 3.25,14C1.9,14 0.77,14.82 0.32,16H0V18H2V17C2,15.9 2.9,15 4,15C5.1,15 6,15.9 6,17H8C8,15.9 8.9,15 10,15C11.1,15 12,15.9 12,17H14C14,15.9 14.9,15 16,15C16.59,15 17.11,15.26 17.47,15.68L18.66,12.7L21.84,13.33L21.43,11.33Z'; + +// ── Time Window Helpers ─────────────────────────────────────────────────────── +export function extractTimeOnly(raw: unknown): string { + const m = String(raw || '').match(/(\d{1,2}):(\d{2})/); + return m ? `${m[1]}:${m[2]}` : ''; +} + +export interface Row { + [key: string]: unknown; +} diff --git a/src/services/fiestaApi.ts b/src/services/fiestaApi.ts index de25097..87661ff 100644 --- a/src/services/fiestaApi.ts +++ b/src/services/fiestaApi.ts @@ -914,3 +914,89 @@ export async function createTenantLocation(input: CreateTenantLocationInput): Pr return fiestaSend('tenants/createtenantlocation', 'POST', input); } +// ════════════════════════════════════════════════════════════════════════════ +// RIDERS / DISPATCH +// ════════════════════════════════════════════════════════════════════════════ + +/** /riders/getriderperiodiclogs?userid=&riderid=&fromdate=&todate=&tenantid=&applocationid= — + * periodic GPS/status snapshots for a rider across a date range. */ +export async function getRiderPeriodicLogs(opts: { + userid?: number; + riderid?: number; + fromdate: string; + todate: string; + tenantid?: number; + applocationid?: number; +}): Promise { + return toRows( + await fiestaGet('riders/getriderperiodiclogs', { + userid: opts.userid, + riderid: opts.riderid, + fromdate: opts.fromdate, + todate: opts.todate, + tenantid: opts.tenantid, + applocationid: opts.applocationid, + }), + ); +} + +/** /riders/getriderlogs?userid=&riderid=&fromdate=&todate=&tenantid=&applocationid= — + * full telemetry logs (GPS traces, events, etc.) for a rider. */ +export async function getRiderLogs(opts: { + userid?: number; + riderid?: number; + fromdate: string; + todate: string; + tenantid?: number; + applocationid?: number; + pageno?: number; + pagesize?: number; +}): Promise { + return toRows( + await fiestaGet('riders/getriderlogs', { + userid: opts.userid, + riderid: opts.riderid, + fromdate: opts.fromdate, + todate: opts.todate, + tenantid: opts.tenantid, + applocationid: opts.applocationid, + pageno: opts.pageno ?? 1, + pagesize: opts.pagesize ?? 200, + }), + ); +} + +/** /partners/getbatchefficiency?partnerid=&tenantid=&fromdate=&todate= — + * batch/trip efficiency metrics. */ +export async function getBatchEfficiency(opts: { + partnerid?: number; + tenantid: number; + fromdate: string; + todate: string; +}): Promise { + return toRows( + await fiestaGet('partners/getbatchefficiency', { + partnerid: opts.partnerid, + tenantid: opts.tenantid, + fromdate: opts.fromdate, + todate: opts.todate, + }), + ); +} + +/** PUT /deliveries/updatedelivery — Manually assign/update a delivery's rider or status. */ +export async function updateDelivery(deliveryid: number, updates: Row): Promise { + return fiestaSend('deliveries/updatedelivery', 'PUT', { + deliveryid, + ...updates, + }); +} + +/** POST /riders/reassigndeliveries — Batch-reassign multiple deliveries to a new rider. */ +export async function reassignDeliveries(opts: { + userid: number; + deliveryids: number[]; +}): Promise { + return fiestaSend('riders/reassigndeliveries', 'POST', opts); +} + diff --git a/src/services/fiestaQueries.ts b/src/services/fiestaQueries.ts index f5c0d77..69de98e 100644 --- a/src/services/fiestaQueries.ts +++ b/src/services/fiestaQueries.ts @@ -32,6 +32,11 @@ import { getCustomerOrders, getRiders, getRiderShifts, + getRiderPeriodicLogs, + getRiderLogs, + getBatchEfficiency, + updateDelivery, + reassignDeliveries, getTenantLocations, getAllTenants, getTenantCustomers, @@ -65,6 +70,9 @@ export const fiestaKeys = { deliveryInsight: (tenantid: number) => ['fiesta', 'deliveryInsight', tenantid] as const, riders: (params: Record) => ['fiesta', 'riders', params] as const, riderShifts: (applocationid: number) => ['fiesta', 'riderShifts', applocationid] as const, + riderPeriodicLogs: (params: Record) => ['fiesta', 'riderPeriodicLogs', params] as const, + riderLogs: (params: Record) => ['fiesta', 'riderLogs', params] as const, + batchEfficiency: (params: Record) => ['fiesta', 'batchEfficiency', params] as const, // v2: bumped when test-row filtering was added to getTenantLocations so any // warm cache holding the old unfiltered (duplicated/junk) rows is bypassed. tenantLocations: (tenantid: number) => ['fiesta', 'tenantLocations', 'v2', tenantid] as const, @@ -256,6 +264,76 @@ export function useFiestaRiderShifts(applocationid: number = FIESTA_APPLOCATION_ }); } +// ── Dispatch / Telemetry ───────────────────────────────────────────────────── +export function useFiestaRiderPeriodicLogs(opts: { + userid?: number; + riderid?: number; + fromdate: string; + todate: string; + tenantid?: number; + applocationid?: number; +}) { + return useQuery({ + queryKey: fiestaKeys.riderPeriodicLogs(opts), + queryFn: () => getRiderPeriodicLogs(opts), + enabled: Boolean(opts.fromdate && opts.todate), + }); +} + +export function useFiestaRiderLogs(opts: { + userid?: number; + riderid?: number; + fromdate: string; + todate: string; + tenantid?: number; + applocationid?: number; + pageno?: number; + pagesize?: number; +}) { + return useQuery({ + queryKey: fiestaKeys.riderLogs(opts), + queryFn: () => getRiderLogs(opts), + enabled: Boolean(opts.fromdate && opts.todate), + }); +} + +export function useFiestaBatchEfficiency(opts: { + partnerid?: number; + tenantid: number; + fromdate: string; + todate: string; +}) { + return useQuery({ + queryKey: fiestaKeys.batchEfficiency(opts), + queryFn: () => getBatchEfficiency(opts), + enabled: Boolean(opts.tenantid && opts.fromdate && opts.todate), + }); +} + +export function useFiestaUpdateDelivery() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { deliveryid: number; updates: Row }) => + updateDelivery(input.deliveryid, input.updates), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['fiesta', 'deliveries'] }); + qc.invalidateQueries({ queryKey: ['fiesta', 'deliverySummary'] }); + }, + }); +} + +export function useFiestaReassignDeliveries() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { userid: number; deliveryids: number[] }) => + reassignDeliveries(input), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['fiesta', 'deliveries'] }); + qc.invalidateQueries({ queryKey: ['fiesta', 'deliverySummary'] }); + }, + }); +} + // ── Tenants / Customers ───────────────────────────────────────────────────────── export function useFiestaTenantLocations(tenantid: number = FIESTA_TENANT_ID) { return useQuery({