diff --git a/src/pages/api/api.js b/src/pages/api/api.js index 554ef54..b23c7af 100644 --- a/src/pages/api/api.js +++ b/src/pages/api/api.js @@ -140,6 +140,36 @@ export const createOptimisationDeliveries = async (deliveryData) => { const response = await axios.post(`https://routes.workolik.com/api/v1/optimization/createdeliveries`, deliveryData.deliveries); return response.data; }; +// ==============================|| reconcileSteps (Preview - validate rider/order step assignments) ||============================== // + +export const reconcileSteps = async ({ riders }) => { + const response = await axios.post( + `https://routes.workolik.com/api/v1/optimization/reconcile-steps`, + { riders } + ); + return response.data; +}; + +// ==============================|| fetchBatchEfficiency (Dispatch - Analysis view) ||============================== // +// Calls POST /api/v1/batch/efficiency with a JSON body { batch, tenant_id }. +// `batch` is one of: 'morning' | 'afternoon' | 'evening'. + +export const fetchBatchEfficiency = async ({ batch, tenantId }) => { + const response = await axios.post( + `https://routes.workolik.com/api/v1/batch/efficiency`, + { + batch, + tenant_id: tenantId + }, + { + headers: { 'Content-Type': 'application/json' }, + // Let success:false envelopes flow through so the UI can surface the + // server's own error message (instead of axios throwing on 4xx/5xx). + validateStatus: () => true + } + ); + return response.data; +}; // ==============================|| finalCreatedeliveries (orders) ||============================== // export const finalCreatedeliveries = async (deliveryData) => { diff --git a/src/pages/nearle/dispatch/Dispatch.css b/src/pages/nearle/dispatch/Dispatch.css index 7e300c8..62501c3 100644 --- a/src/pages/nearle/dispatch/Dispatch.css +++ b/src/pages/nearle/dispatch/Dispatch.css @@ -1394,6 +1394,9 @@ /* Marker status flag (pole + banner above the numbered marker) */ .dispatch-container .cmark { position: relative; + width: 100%; + height: 100%; + display: block; } /* Pulse: a marker glows when its row is hovered in the assignment table */ @@ -1403,6 +1406,63 @@ } @keyframes cmark-pulse { + 0% { + transform: scale(1); + filter: drop-shadow(0 2px 4px rgba(59, 130, 246, 0.4)); + } + + 50% { + transform: scale(1.2); + filter: drop-shadow(0 4px 8px rgba(59, 130, 246, 0.7)); + } + + 100% { + transform: scale(1); + filter: drop-shadow(0 2px 4px rgba(59, 130, 246, 0.4)); + } +} + +/* Leaflet sets overflow:hidden on its panes; the flag pokes up past the marker bounds, + so we let the divIcon container overflow visibly. */ +.dispatch-container .cmark .cmark-flag { + width: 100%; + height: 100%; + display: block; + pointer-events: none; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.25)); +} + +/* Styling when a specific rider is focused (shows circle step sequence badges + flag) */ +.dispatch-container .cmark.is-rider-focused { + border-radius: 50%; + border: 3px solid #fff; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 800; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + letter-spacing: 0.02em; + position: relative; +} + +.dispatch-container .cmark.is-rider-focused .cmark-flag { + position: absolute; + top: -20px; + left: 50%; + transform: translateX(-2px); + width: 18px; + height: 22px; + pointer-events: none; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35)); +} + +.dispatch-container .cmark.is-rider-focused.pulse { + z-index: 1500 !important; + animation: cmark-focused-pulse 0.8s ease-out infinite; +} + +@keyframes cmark-focused-pulse { 0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.55), 0 4px 12px rgba(0, 0, 0, 0.4); transform: scale(1); @@ -1419,92 +1479,10 @@ } } -/* Leaflet sets overflow:hidden on its panes; the flag pokes up past the marker bounds, - so we let the divIcon container overflow visibly. */ -.dispatch-container .cmark .cmark-flag { - position: absolute; - top: -20px; - left: 50%; - transform: translateX(-2px); - width: 18px; - height: 22px; - pointer-events: none; - filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35)); -} - -/* Live rider position bike */ -.dispatch-container .rider-bike { - --rider-color: #475569; - position: relative; - width: 44px; - height: 44px; - display: flex; - align-items: center; - justify-content: center; -} - -.dispatch-container .rider-bike-ring { - position: absolute; - inset: 0; - border-radius: 50%; - background: var(--rider-color); - border: 3px solid #fff; - box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35), 0 0 0 4px rgba(255, 255, 255, 0.25); - animation: rider-pulse 1.8s ease-in-out infinite; -} - -.dispatch-container .rider-bike-svg { - position: relative; - width: 26px; - height: 26px; - z-index: 2; - display: flex; - align-items: center; - justify-content: center; - /* Bike stays upright — direction is conveyed by the route line. */ -} - -.dispatch-container .rider-bike-svg svg { - width: 100%; - height: 100%; - display: block; -} - -.dispatch-container .rider-bike-progress { - position: absolute; - bottom: -16px; - left: 50%; - transform: translateX(-50%); - background: #0f172a; - color: #fff; - font-size: 10px; - font-weight: 800; - letter-spacing: 0.04em; - padding: 2px 6px; - border-radius: 999px; - white-space: nowrap; - border: 1.5px solid #fff; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); - z-index: 3; -} - -@keyframes rider-pulse { - - 0%, - 100% { - box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35), 0 0 0 4px rgba(255, 255, 255, 0.25); - } - - 50% { - box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35), 0 0 0 8px rgba(255, 255, 255, 0.15); - } -} /* Live rider pin (from /partners/getriderlogs/) — colored teardrop with a floating label showing the rider's username + current order. Status drives - the color: green for active, red otherwise. Lives next to the synthetic - bike markers but uses a distinct visual so the operator can tell that this - one is real-GPS, not route-progress estimate. */ + the color: green for active, red otherwise. */ .dispatch-container .live-rider-pin { --pin-color: #16a34a; position: relative; @@ -2009,6 +1987,30 @@ border: 1px solid var(--border); } +.dispatch-container .zone-order-change-rider { + flex-shrink: 0; + width: 26px; + height: 26px; + border-radius: 7px; + border: 1px solid #e2e8f0; + background: #ffffff; + color: #4f46e5; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 15px; + margin-left: 6px; + align-self: flex-start; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.dispatch-container .zone-order-change-rider:hover { + background: #eef2ff; + border-color: #c7d2fe; + color: #4338ca; +} + /* Detail View */ .dispatch-container #route-detail { flex: 1; @@ -2991,6 +2993,26 @@ color: #94a3b8; } +/* Estimated distance to drop, shown under the delivery time only for + in-flight orders. Compact icon + value, muted to defer to the status + pill and ETA above it. */ +.dispatch-container .zone-order-est-drop { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 600; + color: #475569; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.dispatch-container .zone-order-est-drop svg { + font-size: 13px; + color: #0ea5e9; + flex-shrink: 0; +} + /* Customer line — visually the most important text in the card. Inline icon flows with text so there's no awkward gap on short names. */ .dispatch-container .zone-order-customer { @@ -4805,18 +4827,7 @@ color: #16a34a; } -/* Markers */ -.dispatch-container .cmark { - border-radius: 50%; - border: 3px solid #fff; - display: flex; - align-items: center; - justify-content: center; - color: #fff; - font-weight: 800; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); - letter-spacing: 0.02em; -} +/* Markers - styled as clean flags natively in Dispatch.js */ .dispatch-container .kitchen-mark { background: var(--kitchen); @@ -5168,6 +5179,13 @@ line-height: 1.2; } +.dispatch-container .live-rider-popup .pu-rider-name-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + .dispatch-container .live-rider-popup .pu-rider-meta { font-size: 10px; font-weight: 600; @@ -5256,122 +5274,6 @@ border: 1px solid #e2e8f0; } -/* --- Route Rider Popup Custom Styling --- */ -.dispatch-container .route-rider-popup .leaflet-popup-content-wrapper { - padding: 0; - border-radius: 16px; - box-shadow: 0 20px 48px rgba(15, 23, 42, 0.22); - overflow: hidden; - min-width: 240px; - max-width: 260px; - border: 1px solid rgba(255, 255, 255, 0.85); - background: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(12px); - animation: dispatch-popup-in 0.18s cubic-bezier(0.4, 0, 0.2, 1); -} - -.dispatch-container .route-rider-popup .leaflet-popup-content { - min-width: 240px; - margin: 0; -} - -.dispatch-container .route-rider-popup .pu-hdr-live { - background: #f8fafc; - border-bottom: 1px solid #e2e8f0; - padding: 10px 14px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.dispatch-container .route-rider-popup .pu-hdr-left { - display: flex; - align-items: center; - gap: 8px; -} - -.dispatch-container .route-rider-popup .pu-hdr-title { - font-size: 11px; - font-weight: 800; - letter-spacing: 0.05em; - color: #64748b; - text-transform: uppercase; -} - -.dispatch-container .route-rider-popup .pu-rider-profile { - display: flex; - align-items: center; - gap: 12px; - padding: 14px 16px 10px; - background: linear-gradient(180deg, rgba(248, 250, 252, 0.5) 0%, rgba(255, 255, 255, 0) 100%); -} - -.dispatch-container .route-rider-popup .pu-avatar { - width: 38px; - height: 38px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - font-size: 20px; - box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.2); -} - -.dispatch-container .route-rider-popup .pu-rider-info-text { - display: flex; - flex-direction: column; - gap: 2px; -} - -.dispatch-container .route-rider-popup .pu-rider-name { - font-size: 15px; - font-weight: 800; - letter-spacing: -0.01em; - line-height: 1.2; -} - -.dispatch-container .route-rider-popup .pu-rider-meta { - font-size: 10px; - font-weight: 600; - color: #94a3b8; - letter-spacing: 0.02em; -} - -.dispatch-container .route-rider-popup .pu-body-content { - padding: 4px 16px 16px; - display: flex; - flex-direction: column; - gap: 8px; -} - -.dispatch-container .route-rider-popup .pu-info-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 4px 0; - border-bottom: 1px dashed #f1f5f9; -} - -.dispatch-container .route-rider-popup .pu-info-row:last-child { - border-bottom: none; - padding-bottom: 0; -} - -.dispatch-container .route-rider-popup .pu-info-label { - font-size: 10.5px; - font-weight: 700; - color: #94a3b8; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.dispatch-container .route-rider-popup .pu-info-value { - font-size: 12px; - font-weight: 700; - color: #334155; - text-align: right; -} - /* Small purple section label between groups (Timeline, Details). */ .dispatch-container .pu-section-label { margin: 10px 14px 4px; @@ -9122,4 +9024,727 @@ .dispatch-container .cdp-seq-diff.is-nested .cdp-seq-diff-tag { font-size: 12px; padding: 2px 8px; +} + +/* Sidebar Rider Card Est. Meters badge */ +.dispatch-container .rcard-est-meters { + display: inline-flex; + align-items: center; + gap: 3px; + color: var(--accent, #3b82f6); + background: var(--accent-soft, rgba(59, 130, 246, 0.08)); + padding: 2px 8px; + border-radius: 6px; + font-weight: 700; + font-size: 11px; +} + +/* Sidebar Rider Route Detail Order Est. Meters chip */ +.dispatch-container .zone-order-chip.est-meters-chip { + background: rgba(37, 99, 235, 0.08); + border-color: rgba(37, 99, 235, 0.2); + color: #2563eb; +} + +/* Leaflet Map Popup Est. Meters chip */ +.dispatch-container .pu-distance-chip.pu-est-meters { + background: rgba(37, 99, 235, 0.08); + border-color: rgba(37, 99, 235, 0.2); +} + +.dispatch-container .pu-distance-chip.pu-est-meters .pu-distance-icon { + color: #2563eb; +} + +.dispatch-container .pu-distance-chip.pu-est-meters .pu-distance-value { + color: #2563eb; + font-weight: 700; +} + +/* ============================================================ + Top-level Live / Analysis tabs (standalone Dispatch only) + ============================================================ */ +.dispatch-container #dispatch-top-tabs { + display: flex; + gap: 6px; + padding: 8px 12px 0 12px; + border-bottom: 1px solid var(--border); + background: var(--bg); + flex-shrink: 0; +} + +.dispatch-container .dtt-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border: none; + background: transparent; + font-size: 13px; + font-weight: 600; + color: var(--text-muted); + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.15s, border-color 0.15s; +} + +.dispatch-container .dtt-tab:hover { + color: var(--text); +} + +.dispatch-container .dtt-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.dispatch-container .dtt-icon { + display: inline-flex; + align-items: center; + font-size: 15px; +} + +/* ============================================================ + Analysis panel (standalone Dispatch) + ============================================================ */ +.dispatch-container #dispatch-analysis { + flex: 1; + overflow: auto; + padding: 20px; + background: #f8fafc; + display: flex; + flex-direction: column; + gap: 18px; +} + +.dispatch-container .da-intro-title { + font-size: 16px; + font-weight: 700; + color: var(--text); + margin-bottom: 4px; +} + +.dispatch-container .da-intro-sub { + font-size: 12.5px; + color: var(--text-muted); + line-height: 1.5; +} + +.dispatch-container .da-code { + background: #eef2ff; + color: #4f46e5; + padding: 1px 6px; + border-radius: 4px; + margin: 0 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; +} + +.dispatch-container .da-picker-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +@media (max-width: 720px) { + .dispatch-container .da-picker-row { + grid-template-columns: 1fr; + } +} + +.dispatch-container .da-picker { + text-align: left; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 14px; + background: #fff; + cursor: pointer; + transition: box-shadow 0.15s, transform 0.15s, border-color 0.15s; + font-family: inherit; +} + +.dispatch-container .da-picker:hover:not(.is-loading) { + box-shadow: 0 4px 14px -6px rgba(15, 23, 42, 0.15); + transform: translateY(-1px); +} + +.dispatch-container .da-picker.is-loading { + cursor: wait; + opacity: 0.7; +} + +.dispatch-container .da-picker-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.dispatch-container .da-picker-badge { + width: 32px; + height: 32px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 13px; +} + +.dispatch-container .da-picker-meta { + flex: 1; + min-width: 0; +} + +.dispatch-container .da-picker-name { + font-size: 14px; + font-weight: 700; + color: var(--text); + line-height: 1.2; +} + +.dispatch-container .da-picker-range { + font-size: 11px; + color: var(--text-muted); +} + +.dispatch-container .da-picker-status { + flex-shrink: 0; + font-size: 10.5px; + font-weight: 700; + padding: 4px 8px; + border-radius: 12px; +} + +.dispatch-container .da-picker-sub { + font-size: 11.5px; + color: var(--text-muted); +} + +.dispatch-container .da-empty { + border: 1px dashed #cbd5e1; + border-radius: 12px; + padding: 40px; + text-align: center; + background: #fff; + color: #94a3b8; + font-size: 13px; +} + +.dispatch-container .da-result-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px; + align-items: stretch; +} + +.dispatch-container .da-result-card { + background: #fff; + border: 1px solid #e2e8f0; + border-top: 4px solid #6366f1; + border-radius: 12px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.dispatch-container .da-result-head { + display: flex; + justify-content: space-between; + align-items: center; +} + +.dispatch-container .da-result-title { + font-size: 15px; + font-weight: 700; + color: var(--text); +} + +.dispatch-container .da-result-sub { + font-size: 11.5px; + color: var(--text-muted); +} + +.dispatch-container .da-result-refresh { + border: none; + width: 30px; + height: 30px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + cursor: pointer; + transition: filter 0.15s; +} + +.dispatch-container .da-result-refresh:hover { + filter: brightness(0.95); +} + +.dispatch-container .da-result-refresh:disabled { + opacity: 0.5; + cursor: wait; +} + +.dispatch-container .da-metric-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.dispatch-container .da-metric { + padding: 8px 10px; + border-radius: 8px; + background: #f8fafc; + border: 1px solid #eef2f6; +} + +.dispatch-container .da-metric-label { + font-size: 10px; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.4px; + margin-bottom: 2px; +} + +.dispatch-container .da-metric-value { + font-size: 14px; + font-weight: 800; + color: var(--text); + line-height: 1.3; +} + +.dispatch-container .da-riders-label { + font-size: 11px; + font-weight: 700; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.4px; + margin-bottom: 6px; +} + +.dispatch-container .da-riders-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dispatch-container .da-rider-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px 10px; + border-radius: 6px; + background: #f8fafc; + border: 1px solid #eef2f6; +} + +.dispatch-container .da-rider-name { + font-size: 12px; + font-weight: 600; + color: var(--text); + display: inline-flex; + align-items: center; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dispatch-container .da-rider-stat { + font-size: 11px; + color: var(--text-muted); + flex-shrink: 0; +} + +.dispatch-container .da-error { + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + padding: 12px; + color: #991b1b; + display: flex; + flex-direction: column; + gap: 4px; +} + +.dispatch-container .da-error-title { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.3px; + color: #b91c1c; +} + +.dispatch-container .da-error-msg { + font-size: 13px; + line-height: 1.5; + color: #7f1d1d; +} + +.dispatch-container .da-error-meta { + font-size: 11px; + color: #b45353; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +/* ---- Single-batch detail view ---- */ +.dispatch-container .da-detail { + display: flex; + flex-direction: column; + gap: 16px; +} + +.dispatch-container .da-detail-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 16px; + border-radius: 12px; + border: 1px solid #e2e8f0; + border-top: 4px solid #6366f1; + background: #f8fafc; +} + +.dispatch-container .da-detail-title { + font-size: 16px; + font-weight: 800; + color: var(--text); + text-transform: capitalize; +} + +.dispatch-container .da-detail-sub-inline { + font-size: 12.5px; + font-weight: 600; + color: var(--text-muted); + margin-left: 6px; +} + +.dispatch-container .da-detail-sub { + font-size: 11.5px; + color: var(--text-muted); + margin-top: 2px; +} + +.dispatch-container .da-section { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.dispatch-container .da-section-label { + font-size: 12px; + font-weight: 800; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.dispatch-container .da-section-count { + color: #94a3b8; + font-weight: 700; + margin-left: 4px; +} + +.dispatch-container .da-metric-grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +@media (max-width: 720px) { + .dispatch-container .da-metric-grid-3 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* ---- Top recommendation ---- */ +.dispatch-container .da-rec { + background: linear-gradient(135deg, #eef2ff 0%, #f0fdf4 100%); + border: 1px solid #c7d2fe; + border-radius: 10px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.dispatch-container .da-rec-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.dispatch-container .da-rec-action { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 800; + color: #4338ca; + text-transform: capitalize; +} + +.dispatch-container .da-rec-improve { + font-size: 11.5px; + font-weight: 700; + padding: 4px 10px; + border-radius: 14px; +} + +.dispatch-container .da-rec-line { + font-size: 13px; + color: var(--text); + line-height: 1.5; +} + +.dispatch-container .da-rec-desc { + font-size: 12.5px; + color: #475569; + line-height: 1.55; + padding: 8px 10px; + background: #fff; + border: 1px solid #e0e7ff; + border-radius: 8px; +} + +.dispatch-container .da-rec-rules { + background: #fff; + border: 1px solid #e0e7ff; + border-radius: 8px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.dispatch-container .da-rec-rules-head { + font-size: 11px; + font-weight: 700; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.3px; + margin-bottom: 2px; +} + +.dispatch-container .da-rec-rule { + font-size: 12px; + color: #334155; +} + +.dispatch-container .da-rec-rule code { + background: #f1f5f9; + padding: 2px 6px; + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; + color: #1e293b; +} + +.dispatch-container .da-rec-rule-why { + color: var(--text-muted); + font-size: 11.5px; +} + +/* ---- Rider timelines ---- */ +.dispatch-container .da-timeline-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 10px; +} + +.dispatch-container .da-timeline-card { + border: 1px solid #eef2f6; + border-radius: 10px; + padding: 10px 12px; + background: #f8fafc; + display: flex; + flex-direction: column; + gap: 8px; +} + +.dispatch-container .da-timeline-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.dispatch-container .da-timeline-name { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 700; + color: var(--text); + min-width: 0; +} + +.dispatch-container .da-timeline-id { + font-size: 10.5px; + color: var(--text-muted); + font-weight: 600; +} + +.dispatch-container .da-pill { + font-size: 10.5px; + font-weight: 800; + padding: 3px 9px; + border-radius: 12px; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.dispatch-container .da-pill.is-active { + background: #dcfce7; + color: #166534; +} + +.dispatch-container .da-pill.is-idle { + background: #fef3c7; + color: #92400e; +} + +.dispatch-container .da-timeline-mid { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.dispatch-container .da-chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: #fff; + border: 1px solid #e2e8f0; + color: #334155; + font-size: 11px; + font-weight: 600; + padding: 3px 8px; + border-radius: 12px; +} + +/* ---- Substitution opportunities ---- */ +.dispatch-container .da-sub-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.dispatch-container .da-sub-card { + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 12px; + background: #fff; + display: flex; + flex-direction: column; + gap: 8px; +} + +.dispatch-container .da-sub-head { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.dispatch-container .da-sub-title { + font-size: 13px; + color: var(--text); + text-transform: capitalize; +} + +.dispatch-container .da-sub-improve { + font-size: 11.5px; + font-weight: 800; + padding: 3px 10px; + border-radius: 12px; +} + +.dispatch-container .da-sub-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.dispatch-container .da-sub-relieved { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #166534; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 8px; + padding: 6px 10px; +} + +.dispatch-container .da-sub-transfers { + background: #f8fafc; + border: 1px solid #eef2f6; + border-radius: 8px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.dispatch-container .da-sub-transfers-head { + font-size: 11px; + font-weight: 700; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.3px; + margin-bottom: 2px; +} + +.dispatch-container .da-transfer-row { + display: grid; + grid-template-columns: 80px 1fr auto auto; + align-items: center; + gap: 8px; + font-size: 11.5px; + color: var(--text); + padding: 4px 0; + border-bottom: 1px dashed #e2e8f0; +} + +.dispatch-container .da-transfer-row:last-child { + border-bottom: none; +} + +.dispatch-container .da-transfer-id { + font-weight: 800; + color: #1e293b; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.dispatch-container .da-transfer-from { + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dispatch-container .da-transfer-time { + color: #334155; + font-size: 11px; +} + +.dispatch-container .da-transfer-imp { + font-size: 10.5px; + font-weight: 800; + padding: 2px 8px; + border-radius: 10px; } \ No newline at end of file diff --git a/src/pages/nearle/dispatch/Dispatch.js b/src/pages/nearle/dispatch/Dispatch.js index 7ece717..2289042 100644 --- a/src/pages/nearle/dispatch/Dispatch.js +++ b/src/pages/nearle/dispatch/Dispatch.js @@ -8,7 +8,7 @@ import 'leaflet/dist/leaflet.css'; // they share the same road geometry (otherwise they'd stack and read as one). import '../../../utils/leafletPolylineOffset'; import dayjs from 'dayjs'; -import { useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useQueries, useQuery, useMutation } from '@tanstack/react-query'; import axios from 'axios'; import { MdMap, @@ -47,9 +47,11 @@ import { MdClose, MdFormatListBulleted, MdTimer, - MdCalendarToday + MdCalendarToday, + MdInsights, + MdRefresh } from 'react-icons/md'; -import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs } from '../../api/api'; +import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../../api/api'; import { STATUS_STYLES, getStatusStyle, @@ -73,9 +75,6 @@ import logger from '../../../utils/logger'; const COMBINED_PLANNED_COLOR = '#6366f1'; const COMBINED_ACTUAL_COLOR = '#10b981'; -// Phosphor "motorcycle" (filled) — clean side-view bike that reads well at small sizes. -const MOTORBIKE_SVG = ``; - const toNum = (v) => { const n = parseFloat(v); return Number.isFinite(n) ? n : NaN; @@ -436,92 +435,6 @@ const POPUP_TIMELINE = [ { key: 'deliverytime', label: 'Delivered', final: true } ]; -// Compute one "live position" per rider: midpoint between the last delivered drop and the next non-final drop. -// If nothing delivered yet, midpoint is kitchen pickup → first drop. If all delivered, returns null. -const computeRiderPosition = (r) => { - const sorted = [...r.orders].sort((a, b) => { - const tA = a.trip_number || 1; - const tB = b.trip_number || 1; - if (tA !== tB) return tA - tB; - return (a.step || 0) - (b.step || 0); - }); - - const nextIdx = sorted.findIndex((o) => { - const s = String(o.orderstatus || '').toLowerCase(); - return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s); - }); - if (nextIdx === -1) return null; - - const next = sorted[nextIdx]; - if (!hasValidDrop(next)) return null; - const nextLat = toNum(next.droplat || next.deliverylat); - const nextLon = toNum(next.droplon || next.deliverylong); - - // Pick the previous reference point: previous order's drop if available, else this order's pickup (kitchen). - let prevLat; - let prevLon; - const prev = nextIdx > 0 ? sorted[nextIdx - 1] : null; - if (prev && hasValidDrop(prev)) { - prevLat = toNum(prev.droplat || prev.deliverylat); - prevLon = toNum(prev.droplon || prev.deliverylong); - } else if (hasValidPickup(next)) { - prevLat = toNum(pickupLat(next)); - prevLon = toNum(pickupLon(next)); - } else { - return null; - } - - const aerialLat = (prevLat + nextLat) / 2; - const aerialLon = (prevLon + nextLon) / 2; - - const completedCount = sorted.filter((o) => FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase())).length; - - return { - id: r.id, - color: r.color, - riderName: r.riderName, - aerialLat, - aerialLon, - prevLat, - prevLon, - nextLat, - nextLon, - completedCount, - totalCount: sorted.length, - nextStep: next.step || nextIdx + 1, - nextCustomer: next.deliverycustomer || '' - }; -}; - -// Walk a polyline and return the [lat,lon] point at half the total length. -// Uses planar distance — fine at city scale and avoids a haversine import. -const polylineMidpoint = (points) => { - if (!points || points.length < 2) return null; - const segLens = []; - let total = 0; - for (let i = 0; i < points.length - 1; i++) { - const dx = points[i + 1][0] - points[i][0]; - const dy = points[i + 1][1] - points[i][1]; - const d = Math.sqrt(dx * dx + dy * dy); - segLens.push(d); - total += d; - } - if (total === 0) return points[0]; - const target = total / 2; - let acc = 0; - for (let i = 0; i < segLens.length; i++) { - if (acc + segLens[i] >= target) { - const t = (target - acc) / segLens[i]; - return [ - points[i][0] + t * (points[i + 1][0] - points[i][0]), - points[i][1] + t * (points[i + 1][1] - points[i][1]) - ]; - } - acc += segLens[i]; - } - return points[points.length - 1]; -}; - // Build a polyline-ready point list for a sorted trip: // - drop NaN drops // - prepend the first valid pickup we can find (so the line starts at the kitchen) @@ -694,6 +607,38 @@ const Ico = ({ children }) => ( ); +// Batch windows for the standalone Dispatch Analysis view. Maps the UI label +// to the X-Batch-Window header value the backend expects. +const ANALYSIS_BATCH_WINDOWS = [ + { key: 'morning', label: 'Morning', timeRange: '12:00 AM – 8:00 AM', sub: 'Early shift orders', color: '#f59e0b', bg: '#fffbeb', border: '#fde68a' }, + { key: 'afternoon', label: 'Noon', timeRange: '9:00 AM – 12:00 PM', sub: 'Lunch rush window', color: '#10b981', bg: '#ecfdf5', border: '#a7f3d0' }, + { key: 'evening', label: 'Evening', timeRange: '4:00 PM – 7:00 PM', sub: 'Dinner & end-of-day', color: '#6366f1', bg: '#eef2ff', border: '#c7d2fe' } +]; + +// Tolerant field-name lookup so the Analysis card still renders cleanly even +// if the API response uses slightly different keys than expected. +const analysisPick = (obj, keys) => { + for (const k of keys) { + if (obj && obj[k] != null && obj[k] !== '') return obj[k]; + } + return null; +}; +const analysisFormatNum = (v) => { + if (v == null) return '—'; + if (typeof v === 'number') return v.toLocaleString('en-IN'); + const n = parseFloat(v); + if (Number.isFinite(n)) return n.toLocaleString('en-IN'); + return String(v); +}; +const analysisFormatKm = (v) => (v == null ? '—' : `${parseFloat(v).toFixed(1)} km`); +const analysisFormatRupees = (v) => (v == null ? '—' : `₹${parseFloat(v).toFixed(0)}`); +const analysisFormatPct = (v) => { + if (v == null) return '—'; + const n = parseFloat(v); + if (!Number.isFinite(n)) return '—'; + return `${n > 1 ? n.toFixed(1) : (n * 100).toFixed(1)}%`; +}; + const Dispatch = ({ data, embedded = false, @@ -703,12 +648,52 @@ const Dispatch = ({ selectedRiderId, onRiderSelect, // Highlight a single marker (e.g. on table-row hover). Adds a `.pulse` class to that cmark. - pulseOrderId + pulseOrderId, + // Optional. When provided, focused-rider order cards render a small "change rider" + // icon in their header. Receiving callsite owns the rider-picker dialog. Standalone + // /dispatch usage leaves this undefined so the icon never appears there. + onChangeRider }) => { // Default to "By Zone" when the caller passes pre-zoned data (AI preview); fall back to // "By Rider" for the standalone live page where zones are synthesized but riders are primary. const initialViewMode = data?.zones && data.zones.length > 0 ? 'zones' : 'riders'; const [viewMode, setViewMode] = useState(initialViewMode); + + // Top-level page mode for the standalone Dispatch screen: 'live' (existing + // map + sidebar UI) or 'analysis' (batch efficiency panel). Embedded use + // (Preview) never reaches the Analysis switcher, so default doesn't matter + // there. + const [topView, setTopView] = useState('live'); + const [analysisResults, setAnalysisResults] = useState({}); + const [analysisLoadingWindow, setAnalysisLoadingWindow] = useState(null); + const [activeBatchKey, setActiveBatchKey] = useState(null); + + // TODO: wire to real tenant context once the standalone Dispatch screen + // surfaces it. 916 matches the example tenant in the API spec. + const ANALYSIS_TENANT_ID = 916; + + const batchEfficiencyMutation = useMutation({ + mutationFn: fetchBatchEfficiency, + onMutate: (vars) => setAnalysisLoadingWindow(vars.batch), + onSuccess: (resp, vars) => { + setAnalysisResults((prev) => ({ + ...prev, + [vars.batch]: { data: resp, fetchedAt: dayjs().format('HH:mm:ss') } + })); + }, + onSettled: () => setAnalysisLoadingWindow(null) + }); + + const handleFetchAnalysisBatch = (windowKey) => { + setActiveBatchKey(windowKey); + // Re-use cached non-error response if already loaded + const cached = analysisResults[windowKey]; + if (cached && cached.data && cached.data.success !== false) return; + batchEfficiencyMutation.mutate({ + batch: windowKey, + tenantId: ANALYSIS_TENANT_ID + }); + }; const [activeRiders, setActiveRiders] = useState(new Set()); const [internalFocusedRider, setInternalFocusedRider] = useState(null); const [focusedKitchen, setFocusedKitchen] = useState(null); @@ -723,6 +708,11 @@ const Dispatch = ({ // marker; clicking the marker again unpins it. Stored in a ref because this // only drives imperative leaflet calls — no re-render needed. const pinnedPopupsRef = useRef(new Set()); + // Rider ids whose LIVE GPS marker popup was pinned open by a click. Same + // semantics as pinnedPopupsRef but tracked separately because live-GPS + // popups use leaflet's marker-attached (openPopup/closePopup) rather + // than the centered overlay used for order popups. + const pinnedLivePopupsRef = useRef(new Set()); // Short-lived close timer for the general map order/marker popups. // Gives the cursor a ~200ms window to travel from the marker onto the popup // or vice versa without immediately triggering a close. @@ -877,12 +867,12 @@ const Dispatch = ({ queryKey: ['riderPeriodicLog', riderInfoUserid], queryFn: () => getRiderPeriodicLogs(riderInfoUserid), enabled: viewMode === 'rider-info' && riderInfoUserid != null, - // Auto-refresh the rider snapshot every 30s while the view is open and a + // Auto-refresh the rider snapshot every 15s while the view is open and a // rider is selected. Don't poll while the tab is hidden so we don't burn // requests on background tabs. - refetchInterval: viewMode === 'rider-info' && riderInfoUserid != null ? 30_000 : false, + refetchInterval: viewMode === 'rider-info' && riderInfoUserid != null ? 15_000 : false, refetchIntervalInBackground: false, - staleTime: 15 * 1000, + staleTime: 5 * 1000, refetchOnWindowFocus: false }); @@ -1078,15 +1068,13 @@ const Dispatch = ({ // This endpoint returns the exact live GPS position for every rider at the // hub (latitude/longitude/logdate/status). We render those positions as // markers on the main dispatch map so the operator sees where each rider - // actually is — matching the Reports → Riders Logs page. The synthetic - // bike markers driven by riderPositions are route-progress estimates, not - // real GPS, so they stay separate. + // actually is — matching the Reports → Riders Logs page. const { data: ridersLocationLogs } = useQuery({ queryKey: [selectedAppLocationId, selectedDate, ''], queryFn: fetchRidersLogs, - refetchInterval: 30_000, + refetchInterval: 15_000, refetchIntervalInBackground: false, - staleTime: 15 * 1000, + staleTime: 5 * 1000, refetchOnWindowFocus: false }); @@ -1489,9 +1477,6 @@ const Dispatch = ({ }; }, [focusedRider, focusedKitchen, stats]); - // Live rider positions (Rapido-style bikes on the map) - const riderPositions = useMemo(() => riders.map(computeRiderPosition).filter(Boolean), [riders]); - // List of deliveryids tied to the focused rider's orders — used to drive the // batched per-delivery GPS log fetch for Compare mode. Deduped; ignores rows // without a deliveryid (e.g. unaccepted orders) since /getdeliverylogs needs @@ -1927,19 +1912,6 @@ const Dispatch = ({ }); }, [riders, activeRiders, focusedRider, fetchRoute]); - // Fetch a road route for each rider's CURRENT segment (prev stop → next stop) so the - // bike can sit on the polyline rather than the aerial midpoint. Cached under a `seg-` - // trip key so it doesn't collide with the full-trip route under ``. - useEffect(() => { - riderPositions.forEach((p) => { - if (focusedKitchen) return; - if (focusedRider && focusedRider.id !== p.id) return; - if (!activeRiders.has(p.id)) return; - const pts = [[p.prevLat, p.prevLon], [p.nextLat, p.nextLon]]; - fetchRoute(p.id, `seg-${p.nextStep}`, pts); - }); - }, [riderPositions, focusedRider, focusedKitchen, activeRiders, fetchRoute]); - // Auto-advance the selected slot when the wall-clock moves into a new slot's // window — BUT only if the user is still sitting on the slot that's just been // left, so a manual pick (e.g. "let me inspect Slot 1") is never overridden. @@ -2184,6 +2156,24 @@ const Dispatch = ({ const getRiderColor = (rid) => riders.find(r => r.id === rid)?.color || '#475569'; + const calculateEstMeters = (riderId, order) => { + if (!riderId || !order || !hasValidDrop(order)) return null; + const liveLoc = liveRiderLocations.find((l) => String(l.id) === String(riderId)); + if (!liveLoc) return null; + + const dropLatVal = toNum(order.droplat || order.deliverylat); + const dropLonVal = toNum(order.droplon || order.deliverylong); + if (!Number.isFinite(dropLatVal) || !Number.isFinite(dropLonVal)) return null; + + const distKm = haversineKm([liveLoc.lat, liveLoc.lon], [dropLatVal, dropLonVal]); + return Math.round(distKm * 1000); + }; + + const formatMeters = (meters) => { + if (meters === null || meters === undefined) return ''; + return meters >= 1000 ? `${(meters / 1000).toFixed(1)} km` : `${meters} m`; + }; + // Shared rider-card markup, used in the "By Rider" panel and inside the focused-zone detail. const renderRiderCard = (r, i) => { const total = r.orders.length; @@ -2191,6 +2181,12 @@ const Dispatch = ({ FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase()) ).length; const isDone = total > 0 && delivered >= total; + const activeOrder = r.orders.find((o) => { + const s = String(o.orderstatus || '').toLowerCase(); + return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s); + }); + const estMeters = activeOrder ? calculateEstMeters(r.id, activeOrder) : null; + return (
handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}>
@@ -2208,7 +2204,15 @@ const Dispatch = ({
-
{r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km₹{r.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0).toFixed(0)}
+
+ {r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km + {estMeters !== null && ( + + {formatMeters(estMeters)} to drop + + )} + ₹{r.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0).toFixed(0)} +
{r.orders.slice(0, 15).map(o => S{o.step})}
@@ -2238,6 +2242,9 @@ const Dispatch = ({ // the centered overlay wrapper, not this function. const renderOrderPopupContent = (o) => { const statusStyle = getStatusStyle(o.orderstatus); + const orderRiderId = o.rider_id || o.userid; + const isDelivered = FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase()); + const estMeters = isDelivered ? null : calculateEstMeters(orderRiderId, o); return (
@@ -2329,7 +2336,7 @@ const Dispatch = ({ )}
- {(o.kms != null || o.actualkms != null || o.riderkms != null) && ( + {(o.kms != null || o.actualkms != null || (!isDelivered && o.riderkms != null) || estMeters !== null) && (
{o.kms != null && o.kms !== '' && (
@@ -2345,13 +2352,20 @@ const Dispatch = ({ {o.actualkms} km
)} - {o.riderkms != null && o.riderkms !== '' && ( + {!isDelivered && o.riderkms != null && o.riderkms !== '' && (
Rider {parseFloat(o.riderkms).toFixed(2)} km
)} + {estMeters !== null && ( +
+ + Est. to Drop + {formatMeters(estMeters)} +
+ )}
)} @@ -2403,12 +2417,7 @@ const Dispatch = ({ } } - // Use the 'step' field from data, fallback to index. In compare mode - // override with sequenceStep so the marker number matches the timeline. - const seq = compareSeq || o.step || (focusedRider || focusedKitchen ? (ordersToRender.indexOf(o) + 1) : 0); - // Bumped from 22 → 32 so the step number reads at city-level zoom. - const sz = 32; - + const isRiderFocused = !!focusedRider; const statusStyle = getStatusStyle(o.orderstatus); const statusLow = String(o.orderstatus || '').toLowerCase(); const isDelivered = statusLow === 'delivered'; @@ -2421,13 +2430,26 @@ const Dispatch = ({ ${isDelivered ? '' : ''} ` : ''; - const icon = L.divIcon({ - className: '', - iconSize: [sz, sz], - iconAnchor: [sz / 2, sz / 2], - popupAnchor: [0, -28], // Lift popup above the flag, not just the larger 32px marker - html: `
${seq > 0 ? seq : ''}${flagSvg}
` - }); + + const icon = isRiderFocused + ? (() => { + const seq = compareSeq || o.step || (ordersToRender.indexOf(o) + 1); + const sz = 32; + return L.divIcon({ + className: '', + iconSize: [sz, sz], + iconAnchor: [sz / 2, sz / 2], + popupAnchor: [0, -28], + html: `
${seq > 0 ? seq : ''}${flagSvg}
` + }); + })() + : L.divIcon({ + className: '', + iconSize: [24, 30], + iconAnchor: [2, 30], + popupAnchor: [10, -25], // Lift popup above the flag, not just the larger 32px marker + html: `
${flagSvg}
` + }); return ( { - if (popupHoverTimerRef.current) { - clearTimeout(popupHoverTimerRef.current); - popupHoverTimerRef.current = null; - } - setCenterPopupOrder(o); - }, - mouseout: () => { - if (pinnedPopupsRef.current.has(String(o.orderid))) return; - if (popupHoverTimerRef.current) { - clearTimeout(popupHoverTimerRef.current); - } - popupHoverTimerRef.current = setTimeout(() => { - setCenterPopupOrder((cur) => - cur && String(cur.orderid) === String(o.orderid) ? null : cur - ); - popupHoverTimerRef.current = null; - }, 200); - }, click: () => { const id = String(o.orderid); if (pinnedPopupsRef.current.has(id)) { @@ -2966,6 +2969,28 @@ const Dispatch = ({ )} + {!embedded && ( +
+ + +
+ )} + + {(embedded || topView === 'live') && (<>
+ )}
@@ -3674,36 +3724,88 @@ const Dispatch = ({ const statusStyle = getStatusStyle(o.orderstatus); const profit = parseFloat(o.profit || 0); const isLoss = profit < 0; + + const orderRiderId = o.rider_id || o.userid; + const riderForOrder = orderRiderId ? riders.find(r => String(r.id) === String(orderRiderId)) : null; + const activeOrderId = (() => { + if (!riderForOrder) return null; + const sortedAll = [...riderForOrder.orders].sort((a, b) => { + const tA = a.trip_number || 1; + const tB = b.trip_number || 1; + if (tA !== tB) return tA - tB; + return (a.step || 0) - (b.step || 0); + }); + const active = sortedAll.find((x) => { + const s = String(x.orderstatus || '').toLowerCase(); + return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s); + }); + return active ? active.orderid : null; + })(); + const isGoingOn = activeOrderId && o.orderid === activeOrderId; + const estMeters = orderRiderId ? calculateEstMeters(orderRiderId, o) : null; + return (
setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined} >
-
{idx + 1}
+
{o.step || idx + 1}
Order #{o.orderid}
{o.rider_name || o.ridername || 'Unassigned'}
- {o.orderstatus && ( - - {statusStyle.label} - - )} + {(() => { + const actual = formatTimeOnly(o.deliverytime); + const expected = formatTimeOnly(o.expecteddeliverytime); + const isDelivered = FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase()); + const showEstDrop = !isDelivered && estMeters !== null; + if (!o.orderstatus && !actual && !expected && !showEstDrop) return null; + return ( +
+ {o.orderstatus && ( + + {statusStyle.label} + + )} + {(actual || expected) && ( + + {actual || expected} + + )} + {showEstDrop && ( + + {formatMeters(estMeters)} + + )} +
+ ); + })()}
{o.deliverycustomer || '—'}
+ {o.pickupcustomer && ( +
+ {o.pickupcustomer} +
+ )} {(o.deliverysuburb || o.deliveryaddress) && (
{o.deliverysuburb || extractArea(o.deliveryaddress)} @@ -3733,7 +3835,7 @@ const Dispatch = ({ )} - T{o.trip_number || '-'} · S{o.step || '-'} + T{o.trip_number || '-'} · S{o.step || idx + 1}
@@ -3767,17 +3869,37 @@ const Dispatch = ({ const statusStyle = getStatusStyle(o.orderstatus); const profit = parseFloat(o.profit || 0); const isLoss = profit < 0; + + const orderRiderId = o.rider_id || o.userid; + const riderForOrder = orderRiderId ? riders.find(r => String(r.id) === String(orderRiderId)) : null; + const activeOrderId = (() => { + if (!riderForOrder) return null; + const sortedAll = [...riderForOrder.orders].sort((a, b) => { + const tA = a.trip_number || 1; + const tB = b.trip_number || 1; + if (tA !== tB) return tA - tB; + return (a.step || 0) - (b.step || 0); + }); + const active = sortedAll.find((x) => { + const s = String(x.orderstatus || '').toLowerCase(); + return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s); + }); + return active ? active.orderid : null; + })(); + const isGoingOn = activeOrderId && o.orderid === activeOrderId; + const estMeters = orderRiderId ? calculateEstMeters(orderRiderId, o) : null; + return (
setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined} >
- {idx + 1} + {o.step || idx + 1}
Order #{o.orderid}
@@ -3785,14 +3907,41 @@ const Dispatch = ({ {o.rider_name || o.ridername || 'Unassigned'}
- {o.orderstatus && ( - - {statusStyle.label} - - )} + {(() => { + const actual = formatTimeOnly(o.deliverytime); + const expected = formatTimeOnly(o.expecteddeliverytime); + const isDelivered = FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase()); + const showEstDrop = !isDelivered && estMeters !== null; + if (!o.orderstatus && !actual && !expected && !showEstDrop) return null; + return ( +
+ {o.orderstatus && ( + + {statusStyle.label} + + )} + {(actual || expected) && ( + + {actual || expected} + + )} + {showEstDrop && ( + + {formatMeters(estMeters)} + + )} +
+ ); + })()}
@@ -3833,7 +3982,7 @@ const Dispatch = ({ )} - T{o.trip_number || '-'} · S{o.step || '-'} + T{o.trip_number || '-'} · S{o.step || idx + 1}
@@ -4029,77 +4178,6 @@ const Dispatch = ({ } {renderMarkers()} {renderRoutes()} - {!focusedKitchen && riderPositions - .filter(p => activeRiders.has(p.id)) - .filter(p => !focusedRider || focusedRider.id === p.id) - .map(p => { - // Prefer the road polyline midpoint; fall back to aerial midpoint until OSRM responds. - const segPolyline = osrmRoutes[`${p.id}-seg-${p.nextStep}`]; - const roadMid = polylineMidpoint(segPolyline); - const pos = roadMid || [p.aerialLat, p.aerialLon]; - const onRoad = Boolean(roadMid); - const bikeIcon = L.divIcon({ - className: '', - iconSize: [44, 44], - iconAnchor: [22, 22], - popupAnchor: [0, -22], - html: `
-
-
${MOTORBIKE_SVG}
-
${p.completedCount}/${p.totalCount}
-
` - }); - return ( - handleRiderFocus(riders.find((r) => r.id === p.id) || null), - mouseover: (e) => e.target.openPopup(), - mouseout: (e) => e.target.closePopup() - }} - > - -
-
- RIDER ROUTE -
-
-
-
- -
-
-
{p.riderName}
-
Active route details
-
-
-
-
- Progress - - {p.completedCount} / {p.totalCount} delivered - -
-
- Next Stop - - #{p.nextStep} · {p.nextCustomer || '—'} - -
-
- Position - - {onRoad ? 'On road' : 'Estimating…'} - -
-
-
-
- ); - })} {/* Live rider GPS markers from /partners/getriderlogs/. Mirrors the Reports → Riders Logs map: green pin when the rider's last log @@ -4116,6 +4194,25 @@ const Dispatch = ({ .map((r) => { const isActive = r.status === 'active'; const pinColor = isActive ? '#16a34a' : '#dc2626'; + // Look up the rider's in-progress order so the popup can show + // where they're heading next (drop customer/area + originating + // kitchen). Falls back to nothing when every order is final. + const matchingRider = riders.find((rd) => String(rd.id) === String(r.id)); + const nextOrder = matchingRider?.orders + ?.slice() + .sort((a, b) => { + const tA = a.trip_number || 1; + const tB = b.trip_number || 1; + if (tA !== tB) return tA - tB; + return (a.step || 0) - (b.step || 0); + }) + .find((o) => { + const s = String(o.orderstatus || '').toLowerCase(); + return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s); + }); + const nextDropArea = nextOrder + ? (nextOrder.deliverysuburb || extractArea(nextOrder.deliveryaddress)) + : null; const liveIcon = L.divIcon({ className: '', iconSize: [140, 56], @@ -4133,12 +4230,21 @@ const Dispatch = ({ icon={liveIcon} zIndexOffset={2500} eventHandlers={{ - click: () => { - const match = riders.find((rd) => String(rd.id) === String(r.id)); + click: (e) => { + const idStr = String(r.id); + if (pinnedLivePopupsRef.current.has(idStr)) { + pinnedLivePopupsRef.current.delete(idStr); + e.target.closePopup(); + } else { + pinnedLivePopupsRef.current.add(idStr); + e.target.openPopup(); + } + const match = riders.find((rd) => String(rd.id) === idStr); if (match) handleRiderFocus(match); }, - mouseover: (e) => e.target.openPopup(), - mouseout: (e) => e.target.closePopup() + popupclose: () => { + pinnedLivePopupsRef.current.delete(String(r.id)); + } }} > @@ -4149,18 +4255,20 @@ const Dispatch = ({ LIVE GPS - {r.status && ( - - {r.status} - - )}
-
{r.username || `Rider #${r.id}`}
+
+ {r.username || `Rider #${r.id}`} + {r.status && ( + + {r.status} + + )} +
Rider ID: #{r.id}
@@ -4171,6 +4279,33 @@ const Dispatch = ({ #{r.orderid} )} + {nextOrder && ( +
+ Next Stop + + #{nextOrder.step || '?'} · {nextOrder.deliverycustomer || '—'} + +
+ )} + {nextDropArea && ( +
+ Next Location + + {nextDropArea} + +
+ )} + {nextOrder?.pickupcustomer && ( +
+ Pickup + + {nextOrder.pickupcustomer} + +
+ )} {r.contactno && (
Phone @@ -4254,6 +4389,15 @@ const Dispatch = ({ (o) => o.deliveryid != null && String(o.deliveryid) === String(t.deliveryid) ); + const statusStyle = getStatusStyle(t.orderstatus); + const flagSvg = t.orderstatus + ? ` + + + ${isDelivered ? '' : ''} + ` + : ''; + const dropClasses = ['compare-step-pin']; if (isFocusedStep) dropClasses.push('is-focused'); if (isDelivered) dropClasses.push('is-delivered'); @@ -4911,6 +5055,388 @@ const Dispatch = ({
)} + )} + + {!embedded && topView === 'analysis' && ( +
+
+ {ANALYSIS_BATCH_WINDOWS.map((b) => { + const result = analysisResults[b.key]; + const isLoading = analysisLoadingWindow === b.key; + const hasError = result?.data?.success === false; + const statusBg = hasError ? '#fee2e2' : result ? `${b.color}22` : '#f1f5f9'; + const statusFg = hasError ? '#dc2626' : result ? b.color : '#64748b'; + const statusLabel = isLoading + ? 'Loading…' + : hasError + ? '! Failed' + : result + ? `✓ ${result.fetchedAt}` + : 'Fetch'; + const isActive = activeBatchKey === b.key; + return ( + + ); + })} +
+ + {(() => { + if (!activeBatchKey) { + return ( +
+ Pick a batch above to view its efficiency analysis. +
+ ); + } + const activeMeta = ANALYSIS_BATCH_WINDOWS.find((b) => b.key === activeBatchKey); + const cached = analysisResults[activeBatchKey]; + const isLoading = analysisLoadingWindow === activeBatchKey; + + if (isLoading && !cached) { + return ( +
Loading {activeMeta.label} batch…
+ ); + } + if (!cached) return null; + + const raw = cached.data || {}; + if (raw.success === false) { + return ( +
+
+
+
{activeMeta.label} Batch
+
+ {activeMeta.timeRange} · Fetched at {cached.fetchedAt} +
+
+ +
+
+
+ + {raw?.error?.code || 'Request failed'} +
+
+ {raw?.error?.message || 'The server returned an error.'} +
+ {raw?.request_id && ( +
request_id: {raw.request_id}
+ )} +
+
+ ); + } + + const fleet = raw.fleet_summary || {}; + const riders = Array.isArray(raw.rider_timelines) ? raw.rider_timelines : []; + const subs = Array.isArray(raw.substitution_opportunities) ? raw.substitution_opportunities : []; + const rec = raw.top_recommendation; + const win = raw.window || {}; + + const fleetMetrics = [ + { label: 'Total Orders', value: analysisFormatNum(fleet.total_orders) }, + { label: 'Total Riders', value: analysisFormatNum(fleet.total_riders) }, + { label: 'Avg Orders/Rider', value: fleet.orders_per_rider_avg ?? '—' }, + { label: 'Fleet Start', value: fleet.fleet_start || '—' }, + { label: 'Fleet Done', value: fleet.fleet_done || '—' }, + { label: 'Duration', value: fleet.total_duration_minutes != null ? `${fleet.total_duration_minutes} min` : '—' } + ]; + + return ( +
+
+
+
+ {activeMeta.label} Batch + + {raw.date ? ` · ${raw.date}` : ''} + {win.from && win.to ? ` · ${win.from} – ${win.to}` : ''} + +
+
+ Fetched at {cached.fetchedAt} · Input deliveries: {raw.input_delivery_count ?? '—'} +
+
+ +
+ +
+
Fleet Summary
+
+ {fleetMetrics.map((m) => ( +
+
{m.label}
+
{m.value}
+
+ ))} +
+
+ + {rec && ( +
+
Top Recommendation
+
+
+
+ + {(rec.action || 'recommendation').replaceAll('_', ' ')} +
+ {rec.fleet_improvement_minutes != null && ( + 0 + ? { background: '#dcfce7', color: '#166534' } + : { background: '#f1f5f9', color: '#475569' } + } + > + {rec.fleet_improvement_minutes > 0 ? '↑' : '•'} Fleet improves by {rec.fleet_improvement_minutes} min + + )} +
+
+ {rec.idle_rider_name || `Rider ${rec.idle_rider_id}`} + {rec.primary_kitchen && ( + <> · primary kitchen {rec.primary_kitchen} + )} + {rec.second_kitchen && ( + <> → also serve {rec.second_kitchen} after {rec.second_kitchen_dispatch_after || '—'} + )} +
+ {rec.description && ( +
{rec.description}
+ )} + {rec.activate_when?.rules?.length > 0 && ( +
+
+ Activate when ({rec.activate_when.condition || 'AND'}): +
+ {rec.activate_when.rules.map((rule, i) => ( +
+ {rule.field} {rule.operator} {rule.value} + {rule.reason && — {rule.reason}} +
+ ))} +
+ )} +
+
+ )} + + {riders.length > 0 && ( +
+
+ Rider Timelines ({riders.length}) +
+
+ {riders.map((r) => { + const isActive = String(r.status || '').toLowerCase() === 'active'; + return ( +
+
+
+ + {r.name} + #{r.userid} +
+ + {r.status} + +
+
+ {r.kitchen && ( + + {r.kitchen} + + )} + + {r.order_count} orders + + + {r.started_at} → {r.finished_at} + + 30 + ? { background: '#fef3c7', color: '#92400e' } + : undefined + } + > + {r.idle_minutes} min idle + +
+
+ ); + })} +
+
+ )} + + {subs.length > 0 && ( +
+
+ Substitution Opportunities ({subs.length}) +
+
+ {subs.map((s, i) => { + const idle = s.idle_rider || {}; + const relieved = s.most_relieved_rider || {}; + const improved = s.fleet_improvement_minutes ?? 0; + return ( +
+
+
+ {idle.name || `Rider ${idle.userid}`}{' '} + covers {s.target_kitchen} +
+ 0 + ? { background: '#dcfce7', color: '#166534' } + : { background: '#f1f5f9', color: '#475569' } + } + > + Fleet {improved > 0 ? '↑' : '•'} {improved} min + +
+
+ + {s.travel_to_kitchen_km} km + + + {s.travel_to_kitchen_minutes} min travel + + + arrives {s.arrive_at_kitchen} + + + {s.total_orders_transferred} orders + + + +{s.extra_km_for_idle_rider} km for idle rider + +
+ {relieved.name && ( +
+ + Most relieved: {relieved.name}{' '} + ({relieved.original_finish} → {relieved.new_finish}, saves{' '} + {relieved.time_saved_minutes} min) +
+ )} + {Array.isArray(s.orders_to_transfer) && s.orders_to_transfer.length > 0 && ( +
+
Orders transferred
+ {s.orders_to_transfer.map((o) => { + const imp = o.improvement_minutes ?? 0; + return ( +
+ #{o.deliveryid} + + from {o.from_rider_name} + + + {o.original_delivery_time} → {o.estimated_delivery_time} + + 0 + ? { background: '#dcfce7', color: '#166534' } + : imp < 0 + ? { background: '#fee2e2', color: '#991b1b' } + : { background: '#f1f5f9', color: '#475569' } + } + > + {imp > 0 ? '+' : ''}{imp} min + +
+ ); + })} +
+ )} +
+ ); + })} +
+
+ )} +
+ ); + })()} +
+ )} ); diff --git a/src/pages/nearle/dispatch/Preview.js b/src/pages/nearle/dispatch/Preview.js new file mode 100644 index 0000000..07c168b --- /dev/null +++ b/src/pages/nearle/dispatch/Preview.js @@ -0,0 +1,717 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { + Autocomplete, + Backdrop, + Box, + Button, + Card, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, + Tab, + Tabs, + TextField, + Tooltip, + Typography +} from '@mui/material'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import dayjs from 'dayjs'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { HiOutlineArrowLeft } from 'react-icons/hi'; +import { IoReload } from 'react-icons/io5'; +import { MdTwoWheeler, MdSwapHoriz } from 'react-icons/md'; + +import { + createAutomationDeliveries, + createOptimisationDeliveries, + fetchRidersList, + finalCreatedeliveries, + notifyRider, + reconcileSteps +} from '../../api/api'; +import { OpenToast } from 'components/third-party/OpenToast'; +import CSVExport from 'components/third-party/ReactTable'; +import CircularLoader from 'components/CircularLoader'; +import Dispatch from './Dispatch'; +import { stepColor } from './dispatchShared'; + +const tuningTypes = [ + { tuneid: 1, type: 'Balanced', value: 'balanced' }, + { tuneid: 2, type: 'Aggressive Speed', value: 'aggressive_speed' }, + { tuneid: 3, type: 'Fuel Saver', value: 'fuel_saver' }, + { tuneid: 4, type: 'Zone Strict', value: 'zone_strict' } +]; + +// Flatten the API's zoned shape into [{ rider_id, rider_name, orders }] for +// the Reconcile tab UI and the reconcile-API payload. +const extractRiders = (previewData) => { + if (!previewData) return []; + const map = new Map(); + const push = (riderId, riderName, orders) => { + if (riderId == null) return; + const key = String(riderId); + if (!map.has(key)) { + map.set(key, { rider_id: riderId, rider_name: riderName, orders: [] }); + } + const entry = map.get(key); + entry.orders.push(...(orders || [])); + if (!entry.rider_name && riderName) entry.rider_name = riderName; + }; + + if (Array.isArray(previewData.zones) && previewData.zones.length) { + previewData.zones.forEach((z) => { + (z.riders || []).forEach((r) => { + const id = r.rider_id ?? r.userid; + const name = r.rider_name || r.username || `Rider ${id}`; + push(id, name, r.orders); + }); + }); + } else if (Array.isArray(previewData.details)) { + previewData.details.forEach((o) => { + const id = o.rider_id ?? o.userid; + const name = o.rider_name || o.ridername || `Rider ${id}`; + push(id, name, [o]); + }); + } + return Array.from(map.values()); +}; + +// Reverse of extractRiders — flatten rider-grouped list into a details-style +// array (used as the Assign Orders payload). +const flattenRiders = (riders) => { + const out = []; + riders.forEach((r) => { + (r.orders || []).forEach((o) => { + out.push({ + ...o, + rider_id: r.rider_id, + userid: r.rider_id, + rider_name: r.rider_name, + rider: r.rider_name + }); + }); + }); + return out; +}; + +// Move one order from oldRiderId -> newRiderId inside dispatchPreviewData. +// Mutates both the zones[].riders[].orders[] tree (so the Dispatch tab +// renders the change) AND the flat details[] list (so Assign Orders picks +// it up). Returns a NEW preview object (immutable update). +const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderName }) => { + if (!preview) return preview; + const next = JSON.parse(JSON.stringify(preview)); + + // 1) Update flat details list + if (Array.isArray(next.details)) { + next.details = next.details.map((o) => + String(o.orderid) === String(orderId) + ? { ...o, rider_id: newRiderId, userid: newRiderId, rider_name: newRiderName, rider: newRiderName } + : o + ); + } + + // 2) Move within zones[].riders[].orders[] + if (Array.isArray(next.zones)) { + let movedOrder = null; + let homeZoneIdx = -1; + + for (let zi = 0; zi < next.zones.length && !movedOrder; zi++) { + const zone = next.zones[zi]; + if (!Array.isArray(zone.riders)) continue; + for (let ri = 0; ri < zone.riders.length && !movedOrder; ri++) { + const r = zone.riders[ri]; + if (!Array.isArray(r.orders)) continue; + const oi = r.orders.findIndex((o) => String(o.orderid) === String(orderId)); + if (oi !== -1) { + movedOrder = r.orders[oi]; + r.orders.splice(oi, 1); + homeZoneIdx = zi; + } + } + } + + if (movedOrder) { + const updated = { + ...movedOrder, + rider_id: newRiderId, + userid: newRiderId, + rider_name: newRiderName, + rider: newRiderName + }; + let placed = false; + for (const zone of next.zones) { + if (!Array.isArray(zone.riders)) continue; + const target = zone.riders.find( + (r) => String(r.rider_id ?? r.userid) === String(newRiderId) + ); + if (target) { + target.orders = target.orders || []; + target.orders.push(updated); + placed = true; + break; + } + } + if (!placed && homeZoneIdx >= 0) { + next.zones[homeZoneIdx].riders.push({ + rider_id: newRiderId, + userid: newRiderId, + rider_name: newRiderName, + orders: [updated] + }); + } + } + } + + return next; +}; + +// Merge a reconcile-API response { riders:[{rider_id, orders}] } back into +// dispatchPreviewData. Replaces each rider's orders[] in zones (preserving +// zone containment), then rebuilds the flat details list from the new tree. +const applyReconcileResponse = (preview, response) => { + if (!preview || !Array.isArray(response?.riders)) return preview; + const next = JSON.parse(JSON.stringify(preview)); + + const newOrdersByRider = new Map( + response.riders.map((r) => [String(r.rider_id), r.orders || []]) + ); + + if (Array.isArray(next.zones) && next.zones.length) { + next.zones.forEach((zone) => { + if (!Array.isArray(zone.riders)) return; + zone.riders.forEach((r) => { + const key = String(r.rider_id ?? r.userid); + if (newOrdersByRider.has(key)) { + r.orders = newOrdersByRider.get(key); + newOrdersByRider.delete(key); + } else { + // Rider wasn't in the response — keep their existing orders untouched. + } + }); + }); + // Any response riders we didn't place attach to the first zone. + if (newOrdersByRider.size) { + const target = next.zones[0]; + target.riders = target.riders || []; + newOrdersByRider.forEach((orders, riderKey) => { + target.riders.push({ + rider_id: Number(riderKey) || riderKey, + rider_name: orders[0]?.rider_name || `Rider ${riderKey}`, + orders + }); + }); + } + } else { + next.zones = [ + { + zone_name: 'Reconciled', + riders: response.riders.map((r) => ({ + rider_id: r.rider_id, + rider_name: r.rider_name || `Rider ${r.rider_id}`, + orders: r.orders || [] + })) + } + ]; + } + + // Rebuild flat details from the updated zones->riders->orders tree. + const flatDetails = []; + next.zones.forEach((zone) => { + (zone.riders || []).forEach((r) => { + (r.orders || []).forEach((o) => { + flatDetails.push({ + ...o, + rider_id: r.rider_id, + userid: r.rider_id, + rider_name: r.rider_name, + rider: r.rider_name + }); + }); + }); + }); + next.details = flatDetails; + + return next; +}; + +const Preview = () => { + const navigate = useNavigate(); + const location = useLocation(); + const stateData = location.state || {}; + + // SINGLE SOURCE OF TRUTH: every Change Rider / Reconcile / Re-Assign goes + // through this state. The Dispatch tab renders from it, the Reconcile tab + // derives its rider list from it, and Assign Orders sends a flattened copy + // of it to the API. + const [dispatchPreviewData, setDispatchPreviewData] = useState(stateData.dispatchPreviewData || null); + const [csvExportData, setCsvExportData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [tabValue, setTabValue] = useState(0); + + const [reconcileLoading, setReconcileLoading] = useState(false); + const [hasReconciled, setHasReconciled] = useState(false); + + // Change-rider dialog state + const [changeDialogOpen, setChangeDialogOpen] = useState(false); + const [selectedOrder, setSelectedOrder] = useState(null); + const [selectedOldRiderId, setSelectedOldRiderId] = useState(null); + const [selectedNewRider, setSelectedNewRider] = useState(null); + + const aiMode = stateData.aiMode ?? 1; + const selectedMode = stateData.selectedMode || null; + const deliveryData = stateData.deliveryData || []; + const autoRiders = stateData.autoRiders || []; + const absentRidersPayload = stateData.absentRidersPayload || []; + const rider = stateData.rider || null; + + const appId = useMemo(() => { + if (stateData.appId) return stateData.appId; + if (typeof window !== 'undefined') { + const v = localStorage.getItem('applocationid'); + return v ? Number(v) : 0; + } + return 0; + }, [stateData.appId]); + + const { data: ridersList } = useQuery({ + queryKey: ['ridersList', appId], + queryFn: fetchRidersList, + enabled: !!appId, + staleTime: 5 * 60 * 1000 + }); + + // Derived: rider list for the Reconcile tab. Recomputes whenever the cache + // (dispatchPreviewData) changes — so Change Rider / Reconcile both reflect + // here without a separate state. + const reconcileRiders = useMemo(() => extractRiders(dispatchPreviewData), [dispatchPreviewData]); + + // Derived: flat orders list used for the Assign Orders payload + CSV export. + // Always reflects the latest cache state. + const finaldeliveryList = useMemo(() => { + const flat = flattenRiders(reconcileRiders); + if (flat.length) return computeDeliveryAmounts(flat); + if (Array.isArray(dispatchPreviewData?.details)) { + return computeDeliveryAmounts(dispatchPreviewData.details); + } + return []; + }, [reconcileRiders, dispatchPreviewData]); + + useEffect(() => { + const filtered = finaldeliveryList.map((item) => ({ + zone_name: item.zone_name, + ordernotes: item.ordernotes, + rider: item.rider, + step: item.step, + ordertype: item.ordertype, + orderamount: item.orderamount, + riderkms: item.riderkms, + cumulativekms: item.cumulativekms, + baseprice: item.baseprice, + minkm: item.minkm, + priceperkm: item.priceperkm, + kms: item.kms, + actualkms: item.actualkms, + rider_charge: item.rider_charge, + deliveryamt: item.deliveryamt, + deliverycharges: item.deliverycharges, + profit: item.profit + })); + setCsvExportData(filtered); + }, [finaldeliveryList]); + + const notifyRiderMutation = useMutation({ + mutationFn: notifyRider, + onSuccess: () => OpenToast('Notification sent Successfully', 'success', 2000), + onError: (error) => OpenToast(error.message, 'error', 2000) + }); + + const createDeliveryMutation = useMutation({ + mutationFn: aiMode == 0 ? createOptimisationDeliveries : createAutomationDeliveries, + onSuccess: (data) => { + OpenToast('Orders Optimised Successfully', 'success', 2000); + // Brand new response = brand new source of truth. + setDispatchPreviewData(data); + setHasReconciled(false); + setIsLoading(false); + }, + onError: (error) => { + OpenToast(error.message, 'error', 4000); + setIsLoading(false); + }, + onSettled: () => setIsLoading(false) + }); + + const createFinalDeliveryMutation = useMutation({ + mutationFn: finalCreatedeliveries, + onSuccess: () => { + OpenToast('Delivery Created Successfully', 'success', 2000); + setIsLoading(false); + if (rider?.userfcmtoken) notifyRiderMutation.mutate(rider.userfcmtoken); + navigate('/nearle/deliveries'); + }, + onError: (error) => { + OpenToast(error.message, 'error', 4000); + setIsLoading(false); + }, + onSettled: () => setIsLoading(false) + }); + + const reconcileMutation = useMutation({ + mutationFn: reconcileSteps, + onMutate: () => setReconcileLoading(true), + onSuccess: (data) => { + if (Array.isArray(data?.riders)) { + setDispatchPreviewData((prev) => applyReconcileResponse(prev, data)); + setHasReconciled(true); + OpenToast('Steps reconciled — preview updated', 'success', 2000); + } else { + OpenToast('Reconcile returned no rider data', 'warning', 3000); + } + }, + onError: (error) => { + OpenToast(error.message || 'Reconcile failed', 'error', 4000); + }, + onSettled: () => setReconcileLoading(false) + }); + + const handleCreateDelivery = (tune) => { + setIsLoading(true); + if (aiMode == 0) { + createDeliveryMutation.mutate({ deliveries: deliveryData }); + } else if (selectedMode && selectedMode?.value == 1) { + createDeliveryMutation.mutate({ + deliveries: deliveryData, + hypertuning_params: tune || null, + selectedMode, + absent_riders: absentRidersPayload + }); + } else { + createDeliveryMutation.mutate({ + data: { + orders: deliveryData, + riders: autoRiders, + config: { pay_type: 'hourly', base_pay: 300.0, strategy: 'multi_trip' }, + absent_riders: absentRidersPayload + }, + selectedMode + }); + } + }; + + const handleFinalCreateDelivery = () => { + if (!finaldeliveryList?.length) { + OpenToast('No deliveries to assign', 'error', 3000); + return; + } + setIsLoading(true); + createFinalDeliveryMutation.mutate({ deliveries: finaldeliveryList }); + }; + + const handleReconcile = () => { + if (!reconcileRiders.length) { + OpenToast('No riders to reconcile', 'warning', 3000); + return; + } + // Payload built straight from the cache — whatever Change Rider edited + // is what gets sent. + reconcileMutation.mutate({ + riders: reconcileRiders.map((r) => ({ + rider_id: r.rider_id, + orders: r.orders + })) + }); + }; + + const openChangeRider = (oldRider, order) => { + const oldId = + oldRider?.rider_id ?? oldRider?.id ?? order?.rider_id ?? order?.userid ?? null; + setSelectedOldRiderId(oldId); + setSelectedOrder(order); + setSelectedNewRider(null); + setChangeDialogOpen(true); + }; + + const confirmChangeRider = () => { + if (!selectedNewRider || !selectedOrder) return; + const newRiderId = selectedNewRider.userid; + const newRiderName = + selectedNewRider.label || + `${selectedNewRider.firstname || ''} ${selectedNewRider.lastname || ''}`.trim() || + `Rider ${newRiderId}`; + + setDispatchPreviewData((prev) => + moveOrderInPreviewData(prev, { + orderId: selectedOrder.orderid, + oldRiderId: selectedOldRiderId, + newRiderId, + newRiderName + }) + ); + setHasReconciled(false); + setChangeDialogOpen(false); + OpenToast('Rider changed — click Reconcile to verify steps', 'info', 2500); + }; + + return ( + + theme.zIndex.modal + 1 }} + open={isLoading} + > + + + + + + + + navigate('/nearle/orders')} + sx={{ bgcolor: 'action.hover', '&:hover': { bgcolor: 'action.selected' } }} + > + + + + + Assign Orders + + + + option.type} + sx={{ minWidth: 250, maxWidth: 600, flex: 1 }} + renderInput={(params) => } + onChange={(e, val, reason) => { + if (reason === 'clear') handleCreateDelivery(null); + else handleCreateDelivery(val.value); + }} + /> + + + + + + + + setTabValue(v)} sx={{ minHeight: 40 }}> + + + + + + + {tabValue === 0 && dispatchPreviewData && ( + openChangeRider(focusedRider, order)} + /> + )} + {tabValue === 1 && ( + + {reconcileRiders.length === 0 ? ( + + No rider data available to reconcile. + + ) : ( + + + {hasReconciled + ? 'Steps have been reconciled. The Dispatch tab and Assign payload are updated.' + : 'Click a numbered step to change its rider. Hit Reconcile to verify the corrected steps with the server.'} + + + {reconcileRiders.map((r) => { + const totalKms = r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0); + return ( + + + + + + + + + {r.rider_name} + + + ID: {r.rider_id} + + + + + + + + + + + {r.orders.map((o, idx) => { + const stepNum = o.step ?? idx + 1; + const color = stepColor(Number(stepNum) - 1); + return ( + +
Order #{o.orderid}
+
{o.deliveryaddress || o.deliverysuburb || ''}
+
Click to change rider
+
+ } + > + openChangeRider(r, o)} + sx={{ + width: 36, + height: 36, + borderRadius: '50%', + bgcolor: color, + color: '#fff', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 800, + fontSize: 14, + cursor: 'pointer', + boxShadow: + '0 0 0 2px rgba(255,255,255,0.6), 0 1px 3px rgba(15,23,42,0.15)', + transition: 'transform 0.15s', + '&:hover': { transform: 'scale(1.08)' } + }} + > + {stepNum} + + + ); + })} + + + ); + })} + + + + + + )} +
+ )} +
+ + + + + + + + + setChangeDialogOpen(false)} maxWidth="xs" fullWidth> + Change Rider + + + Move order #{selectedOrder?.orderid} (step {selectedOrder?.step ?? '—'}) to: + + + o?.label || `${o?.firstname || ''} ${o?.lastname || ''}`.trim() || '' + } + value={selectedNewRider} + onChange={(e, val) => setSelectedNewRider(val)} + renderInput={(params) => } + /> + + + + + + + + ); +}; + +// Mirrors the orders.js deliveryamt recalc — applied at render-time so the +// Assign payload always reflects the current cache without a useEffect. +function computeDeliveryAmounts(list) { + return list.map((item) => { + const cumulativeKms = Number(item.cumulativekms || 0); + const minKm = Number(item.minkm || 0); + const basePrice = Number(item.baseprice || 0); + const pricePerKm = Number(item.priceperkm || 0); + if (cumulativeKms <= minKm) return { ...item, deliveryamt: basePrice }; + return { ...item, deliveryamt: (cumulativeKms - minKm) * pricePerKm + basePrice }; + }); +} + +export default Preview; diff --git a/src/pages/nearle/orders/OrdersRedesign.css b/src/pages/nearle/orders/OrdersRedesign.css index 875087c..0302fa0 100644 --- a/src/pages/nearle/orders/OrdersRedesign.css +++ b/src/pages/nearle/orders/OrdersRedesign.css @@ -1096,13 +1096,11 @@ } .gradient-btn-create:hover { - transform: translateY(-1px) !important; filter: brightness(1.04); box-shadow: 0 8px 18px -4px rgba(24, 144, 255, 0.40), 0 3px 8px rgba(101, 56, 122, 0.18) !important; } .gradient-btn-create:active { - transform: translateY(0) !important; filter: brightness(0.98); } diff --git a/src/pages/nearle/orders/multipleOrders.js b/src/pages/nearle/orders/multipleOrders.js index 147f814..fbca5e0 100644 --- a/src/pages/nearle/orders/multipleOrders.js +++ b/src/pages/nearle/orders/multipleOrders.js @@ -1,23 +1,11 @@ -import React from 'react'; -import Loader from 'components/Loader'; -import { useEffect, useState, Fragment, useRef } from 'react'; -import { useTheme } from '@mui/material/styles'; -import MainCard from 'components/MainCard'; +import React, { useEffect, useState, useRef, Fragment } from 'react'; import axios from 'axios'; -import ClearIcon from '@mui/icons-material/Clear'; -import { SearchOutlined, CloseOutlined, ExclamationCircleOutlined, FileAddOutlined } from '@ant-design/icons'; -import { Empty } from 'antd'; -import MyLocationIcon from '@mui/icons-material/MyLocation'; -import { DatePicker } from '@mui/x-date-pickers/DatePicker'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; -import dayjs from 'dayjs'; -var utc = require('dayjs/plugin/utc'); -dayjs.extend(utc); -import { enqueueSnackbar } from 'notistack'; -import { useNavigate } from 'react-router'; import Papa from 'papaparse'; import * as XLSX from 'xlsx'; +import dayjs from 'dayjs'; +import { useNavigate } from 'react-router'; +import { useTheme } from '@mui/material/styles'; +import { enqueueSnackbar } from 'notistack'; import { FormControl, @@ -26,6 +14,7 @@ import { Typography, Stack, Box, + Card, Button, TextField, Autocomplete, @@ -47,12 +36,54 @@ import { TableRow, Paper, TableHead, - FormLabel, - RadioGroup, - Radio, - Backdrop + Backdrop, + Chip, + Tooltip } from '@mui/material'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import ClearIcon from '@mui/icons-material/Clear'; +import MyLocationIcon from '@mui/icons-material/MyLocation'; +import { + SearchOutlined, + CloseOutlined, + ExclamationCircleOutlined, + FileAddOutlined, + CalendarOutlined, + ClockCircleOutlined, + FileTextOutlined, + InboxOutlined, + LockOutlined, + CheckCircleFilled +} from '@ant-design/icons'; +import { Empty } from 'antd'; +import { FaUser, FaTruck, FaUsers, FaPaperPlane, FaRoute, FaMoneyBillWave, FaBoxes, FaReceipt } from 'react-icons/fa'; +import { FaLocationDot } from 'react-icons/fa6'; +import { MdOutlineCloudUpload } from 'react-icons/md'; + +import Loader from 'components/Loader'; import CircularLoader from 'components/CircularLoader'; +import AnimateButton from 'components/@extended/AnimateButton'; +import './OrdersRedesign.css'; + +var utc = require('dayjs/plugin/utc'); +dayjs.extend(utc); + +// ============================== small style tokens ============================== +const cellHeaderSx = { + fontSize: 11.5, + fontWeight: 700, + color: '#475569', + py: 0.75, + px: 1 +}; + +const cellBodySx = { + fontSize: 12, + py: 0.6, + px: 1 +}; const MultipleOrders = () => { const navigate = useNavigate(); @@ -60,544 +91,357 @@ const MultipleOrders = () => { const locationRef = useRef(null); const tenantRef = useRef(null); const userid = localStorage.getItem('userid'); + + // ============================== state ============================== const [locations, setLocations] = useState([]); const [tenantlist, setTenantlist] = useState([]); + const [tenantLocations, setTenantlocations] = useState([]); const [loading, setLoading] = useState(false); const [btnLoading, setBtnLoading] = useState(false); + const [appId, setAppId] = useState(0); - const [tenantLocations, setTenantlocations] = useState([]); const [tenantid, setTenantid] = useState(0); const [locationid, setLocationid] = useState(0); + const [tenantValue, setTenantValue] = useState(null); + const [locationValue, setLocationValue] = useState(null); + const [basePrice, setBasePrice] = useState(0); const [pricePerKm, setPricePerKm] = useState(0); const [minKm, setMinKm] = useState(0); + const [pickCust, setPickCust] = useState(null); const [dropCust, setDropCust] = useState([]); + const [customerlist, setCustomerlist] = useState([]); const [isCustomerOpen, setIsCustomerOpen] = useState(false); const [searchCustList, setSearchCustList] = useState(''); - const [customerlist, setCustomerlist] = useState([]); - const [timeslotarr, setTimeslotarr] = useState([]); + const [startdate, setStartdate] = useState(dayjs().format('MM-DD-YYYY')); const [selectedtime, setSelectedtime] = useState(''); - const [alertmessage, setAlertmessage] = useState(''); + const [pickupSlotsList, setPickupSlotsList] = useState(null); + const [pickupSlot, setPickupSlot] = useState(null); + const [otherinstructions, setOtherinstructions] = useState(''); const [admintoken, setAdmintoken] = useState(); + const [totaldist, settotaldist] = useState(0); const [totalAmt, settotalAmt] = useState(0); const [totalQty, settotalQty] = useState(0); const [totalCash, settotalCash] = useState(0); - const [users, setUsers] = useState([]); + const [uploadType, setUploadType] = useState(null); - const [tenantValue, setTenantValue] = useState(null); - const [locationValue, setLocationValue] = useState(null); - const [pickupSlotsList, setPickupSlotsList] = useState(null); - const [pickupSlot, setPickupSlot] = useState(null); + const [users, setUsers] = useState([]); + const [fileName, setFileName] = useState(''); - // to clear the tenant and location autocomplete - useEffect(() => { - setTenantid(0); - setTenantValue(null); - setLocationid(0); - setLocationValue(null); - }, [appId]); - // to clear the location autocomplete - useEffect(() => { - setLocationid(0); - setLocationValue(null); - }, [tenantid]); + // Stable dedup cache for OpenToast. Was a `let` inside the component body, + // which got recreated on every render and broke the dedup. A ref persists + // across renders without triggering re-renders itself. + const toastCacheRef = useRef({}); - useEffect(() => { - if (timeslotarr[0]) { - let arr = []; - timeslotarr.map((val) => { - if (dayjs().diff(dayjs(`${dayjs(startdate).format('MM-DD-YYYY')} ${dayjs(val).format('HH:mm:ss')}`), 'm') <= 0) { - arr.push(val); - } - }); - } - }, [timeslotarr]); - - // =============================================== || opentoast || =============================================== + // ============================== toast ============================== const opentoast = (message, variant, time) => { enqueueSnackbar(message, { variant: variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: time ? time : 1500 }); - console.log(alertmessage); }; - // 🔹 Smart toast wrapper — prevents duplicate toasts for same message within 3 seconds - let toastCache = {}; - const OpenToast = (message, type = 'info', timeout = 10000) => { + const OpenToast = (message, type = 'info', timeout = 3000) => { const key = `${type}-${message}`; - if (toastCache[key]) return; // skip duplicates - opentoast(message, type, timeout); // your existing toast/snackbar - toastCache[key] = true; - setTimeout(() => delete toastCache[key], 3000); // reset after delay + if (toastCacheRef.current[key]) return; + opentoast(message, type, timeout); + toastCacheRef.current[key] = true; + setTimeout(() => delete toastCacheRef.current[key], 3000); }; - // ==============================|| fetchAppLocations ||============================== // + // ============================== effects: reset chains ============================== + useEffect(() => { + // appId change → clear downstream selections (tenant, location, customers) + setTenantid(0); + setTenantValue(null); + setLocationid(0); + setLocationValue(null); + setTenantlocations([]); + setPickCust(null); + setDropCust([]); + setUsers([]); + setUploadType(null); + setFileName(''); + setPickupSlotsList(null); + setPickupSlot(null); + setSelectedtime(''); + }, [appId]); + useEffect(() => { + // tenantid change → clear location/customers (keep appId) + setLocationid(0); + setLocationValue(null); + setDropCust([]); + setUsers([]); + setUploadType(null); + setFileName(''); + }, [tenantid]); + + // ============================== fetchAppLocations ============================== const fetchAppLocations = async () => { setLoading(true); - try { const locationRes = await axios.get(`${process.env.REACT_APP_URL}/partners/getlocations/?userid=${userid}`); - console.log('fetchAppLocations', locationRes.data.details); - setLocations(locationRes.data.details); + setLocations(locationRes.data.details || []); } catch (err) { - console.log('locationRes', err); OpenToast(err.message, 'error', 5000); } finally { setLoading(false); } }; + useEffect(() => { fetchAppLocations(); }, []); - // ===================================================== || fetchtenantinfolist || ===================================================== - + // ============================== fetchtenantinfolist ============================== const fetchtenantinfolist = async () => { setLoading(true); - await axios - .get(`${process.env.REACT_APP_URL}/tenants/gettenants/?applocationid=${appId}&status=active`) - - .then((res) => { - console.log(res); - if (res.data.status) { - let arr = []; - res.data.details.map((val) => { - arr.push({ - ...val, - label: `${val.tenantname}` - }); - }); - setTenantlist(arr); - } - setLoading(false); - }) - .catch((err) => { - console.log(err); - setLoading(false); - }); - }; - useEffect(() => { - appId && fetchtenantinfolist(); - }, [appId]); - // ============================================= || fetchTenantPricing || ============================================= - - const fetchTenantPricing = async (id) => { try { - const pricingResponse = await axios.get(`${process.env.REACT_APP_URL}/tenants/gettenantpricing/?tenantid=${id}`); - console.log('pricingResponse', pricingResponse.data.details); - setBasePrice(pricingResponse.data.details.baseprice); - setPricePerKm(pricingResponse.data.details.priceperkm); - setMinKm(pricingResponse.data.details.minkm); - } catch (error) { - console.log('fetchTenantPricing error', error); - } - }; - // ============================================= || gettenantlocations (branches) || ============================================= - const gettenantlocations = async (id) => { - try { - const res = await axios.get(`${process.env.REACT_APP_URL}/tenants/gettenantlocations/?tenantid=${id}`); - console.log('gettenantlocations', res.data.details); - if (res.data.details.length == 1) { - setTenantlocations(res.data.details); - setPickCust(res.data.details[0]); - setLocationid(res.data.details[0].locationid); - setLocationValue(res.data.details[0].locationid); - setPickupSlotsList(res.data.details[0].slots); - } else { - setTenantlocations(res.data.details); + const res = await axios.get(`${process.env.REACT_APP_URL}/tenants/gettenants/?applocationid=${appId}&status=active`); + if (res.data.status) { + const arr = (res.data.details || []).map((val) => ({ ...val, label: `${val.tenantname}` })); + setTenantlist(arr); } } catch (err) { - console.log('gettenantlocations', err); - } - }; - // ========================================================= || clientdetails || ========================================================= - const clientdetails = async () => { - try { - let url = - searchCustList == '' - ? `${process.env.REACT_APP_URL}/customers/gettenantcustomers/?tenantid=${tenantid}&pageno=1&pagesize=30` - : `${process.env.REACT_APP_URL}/customers/search/?tenantid=${tenantid}&keyword=${searchCustList}`; - await axios - .get(url) - .then((res) => { - if (res.data.status) { - console.log('clientdetails', res.data.details); - - setCustomerlist(res.data.details); - let arr = []; - res.data.details.map((val) => { - arr.push({ - label: `${val.firstname} | ${val.contactno}`, - ...val - }); - }); - } - }) - .catch((err) => { - console.log(err); - opentoast('server error', 'warning'); - }); - } catch (err) { - console.log(err); - } - }; - useEffect(() => { - if (tenantid) { - clientdetails(); - } - }, [searchCustList.length > 3, searchCustList == '', tenantid]); - - // ========================================================= || calculateTotal(dist , charge) || ========================================================= - const calculateTotal = () => { - let a1 = 0; - let a2 = 0; - let a3 = 0; - let a4 = 0; - dropCust.map((customer) => { - a1 += customer.distance; - a2 += customer.totalcharge; - a3 += customer.quantity; - a4 += customer.collectionamt; - }); - settotaldist(a1); - settotalAmt(a2); - settotalQty(a3); - settotalCash(a4); - }; - useEffect(() => { - calculateTotal(); - }, [dropCust]); - - // ========================================================= || handleCheckboxChange || ========================================================= - const handleCheckboxChange = async (event, customer) => { - setLoading(true); - if (event.target.checked) { - // If the checkbox is checked, calculate the distance and add the customer - try { - const obj = await calculateDistance(customer); - const { roundedDistance, totalcharge } = obj; - // Create a new customer object with the distance property - const updatedCustomer = { - ...customer, - distance: roundedDistance, - totalcharge: totalcharge - }; - - // Add the updated customer object to dropCust - setDropCust((prevDropCust) => [...prevDropCust, updatedCustomer]); - - // Log the rounded distance - // console.log(`Rounded Distance: ${roundedDistance} km`); - } catch (error) { - console.error('Failed to calculate distance:', error); - } finally { - setLoading(false); - } - } else { - // If the checkbox is unchecked, remove the customer from dropCust - setDropCust((prevDropCust) => { - return prevDropCust.filter((cust) => cust.customerid !== customer.customerid); - }); - setLoading(false); - } - }; - // ========================================================= || handleCheckboxChange1 || ========================================================= - // const handleCheckboxChange1 = async (customer) => { - // console.log('customer', customer); - // setLoading(true); - // try { - // const obj = await calculateDistance(customer); - // const { roundedDistance, totalcharge } = obj; - // // Create a new customer object with the distance property - // const updatedCustomer = { - // ...customer, - // distance: roundedDistance, - // totalcharge: totalcharge - // }; - - // // Add the updated customer object to dropCust - // setDropCust((prevDropCust) => [...prevDropCust, updatedCustomer]); - - // // Log the rounded distance - // console.log(`Rounded Distance: ${roundedDistance} km`); - // setLoading(false); - // } catch (error) { - // console.error('Failed to calculate distance:', error); - // } - // }; - const handleCheckboxChange1 = async (customer) => { - console.log('customer', customer); - - setLoading(true); - - try { - setDropCust((prevDropCust) => { - const isAlreadySelected = prevDropCust.some((c) => c.firstname === customer.firstname); - - // 🔴 REMOVE if already exists - if (isAlreadySelected) { - return prevDropCust.filter((c) => c.firstname !== customer.firstname); - } - - // 🟢 ADD if not exists (calculate distance) - return prevDropCust; - }); - - // Only calculate distance if customer is not already added - const alreadyExists = dropCust.some((c) => c.firstname === customer.firstname); - - if (!alreadyExists) { - const obj = await calculateDistance(customer); - const { roundedDistance, totalcharge } = obj; - - const updatedCustomer = { - ...customer, - distance: roundedDistance, - totalcharge - }; - - setDropCust((prevDropCust) => [...prevDropCust, updatedCustomer]); - - console.log(`Rounded Distance: ${roundedDistance} km`); - } - } catch (error) { - console.error('Failed to calculate distance:', error); + OpenToast('Failed to load clients', 'warning', 3000); } finally { setLoading(false); } }; - // ========================================================= || calculateDistance || ========================================================= + useEffect(() => { + if (appId) fetchtenantinfolist(); + }, [appId]); - // 🔹 Main distance calculation function + // ============================== fetchTenantPricing ============================== + const fetchTenantPricing = async (id) => { + try { + const res = await axios.get(`${process.env.REACT_APP_URL}/tenants/gettenantpricing/?tenantid=${id}`); + const d = res.data.details || {}; + setBasePrice(d.baseprice || 0); + setPricePerKm(d.priceperkm || 0); + setMinKm(d.minkm || 0); + } catch (error) { + console.log('fetchTenantPricing error', error); + } + }; + + // ============================== gettenantlocations ============================== + const gettenantlocations = async (id) => { + try { + const res = await axios.get(`${process.env.REACT_APP_URL}/tenants/gettenantlocations/?tenantid=${id}`); + const details = res.data.details || []; + if (details.length === 1) { + setTenantlocations(details); + setPickCust(details[0]); + setLocationid(details[0].locationid); + setLocationValue(details[0]); + setPickupSlotsList(details[0].slots); + } else { + setTenantlocations(details); + } + } catch (err) { + console.log('gettenantlocations', err); + } + }; + + // ============================== clientdetails ============================== + const clientdetails = async () => { + try { + const url = + searchCustList === '' + ? `${process.env.REACT_APP_URL}/customers/gettenantcustomers/?tenantid=${tenantid}&pageno=1&pagesize=30` + : `${process.env.REACT_APP_URL}/customers/search/?tenantid=${tenantid}&keyword=${searchCustList}`; + const res = await axios.get(url); + if (res.data.status) setCustomerlist(res.data.details || []); + } catch (err) { + console.log(err); + opentoast('server error', 'warning'); + } + }; + + useEffect(() => { + if (!tenantid) return; + // Light debounce so we don't fire on every keystroke. + const t = setTimeout(() => { + if (searchCustList === '' || searchCustList.length > 2) clientdetails(); + }, 250); + return () => clearTimeout(t); + }, [searchCustList, tenantid]); + + // ============================== totals ============================== + useEffect(() => { + let a1 = 0; + let a2 = 0; + let a3 = 0; + let a4 = 0; + dropCust.forEach((c) => { + a1 += Number(c.distance) || 0; + a2 += Number(c.totalcharge) || 0; + a3 += Number(c.quantity) || 0; + a4 += Number(c.collectionamt) || 0; + }); + settotaldist(a1); + settotalAmt(a2); + settotalQty(a3); + settotalCash(a4); + }, [dropCust]); + + // ============================== distance (Google) ============================== const calculateDistance = async (customer) => { - const service = new google.maps.DistanceMatrixService(); + if (typeof window === 'undefined' || !window.google?.maps?.DistanceMatrixService) { + throw new Error('Google Maps not loaded'); + } + const service = new window.google.maps.DistanceMatrixService(); - // Helper: safely get distance matrix - const getDistanceMatrix = (origins, destinations) => { - return new Promise((resolve, reject) => { - // 2; + const getDistanceMatrix = (origins, destinations) => + new Promise((resolve, reject) => { try { - if (!origins || !destinations) { - return reject(new Error('Origin or destination data missing.')); - } + if (!origins || !destinations) return reject(new Error('Origin or destination data missing.')); service.getDistanceMatrix( { - origins: [new google.maps.LatLng(origins.latitude, origins.longitude)], - destinations: [new google.maps.LatLng(destinations.latitude, destinations.longitude)], + origins: [new window.google.maps.LatLng(origins.latitude, origins.longitude)], + destinations: [new window.google.maps.LatLng(destinations.latitude, destinations.longitude)], travelMode: 'DRIVING', - unitSystem: google.maps.UnitSystem.METRIC + unitSystem: window.google.maps.UnitSystem.METRIC }, (response, status) => { - if (status === 'OK') { - resolve(response); - } else { - reject(new Error(`Google API error: ${status}`)); - } + if (status === 'OK') resolve(response); + else reject(new Error(`Google API error: ${status}`)); } ); } catch (err) { reject(new Error(`Unexpected error inside DistanceMatrixService: ${err.message}`)); } }); - }; try { - // --- Input validation --- - if (!customer || typeof customer !== 'object') { - throw new Error('Invalid customer data: expected an object.'); - } + if (!customer || typeof customer !== 'object') throw new Error('Invalid customer data.'); + if (!pickCust || typeof pickCust !== 'object') throw new Error('Origin (pickCust) data missing or invalid.'); - if (!pickCust || typeof pickCust !== 'object') { - throw new Error('Origin (pickCust) data missing or invalid.'); - } - - // --- Call Google Maps API --- const response = await getDistanceMatrix(pickCust, customer); + const distVal = response?.rows?.[0]?.elements?.[0]?.distance?.value; + if (distVal == null) throw new Error('Malformed Distance Matrix response: missing distance value.'); - // --- Validate response structure --- - if (!response.rows?.[0]?.elements?.[0] || !response.rows[0].elements[0].distance?.value) { - throw new Error('Malformed Distance Matrix response: missing distance value.'); - } - // --- Compute distance --- - const distanceInMeters = response.rows[0].elements[0].distance.value; - const distanceInKilometers = distanceInMeters / 1000; - const roundedDistance = Math.round(distanceInKilometers); - - // --- Calculate total charge --- - let totalcharge; - if (roundedDistance < minKm) { - totalcharge = basePrice; - } else { - totalcharge = (roundedDistance - minKm) * pricePerKm + basePrice; - } + const km = distVal / 1000; + const roundedDistance = Math.round(km); + const totalcharge = + roundedDistance < minKm ? basePrice : (roundedDistance - minKm) * pricePerKm + basePrice; return { roundedDistance, totalcharge }; } catch (error) { - // --- Categorized smart error handling --- - console.log('on calculateDistance', error.message); if (error.message.includes('Google API')) { - console.log('🚨 Google Maps API Error:', error.message); - OpenToast('Invalid file format, upload valid file', 'error', 5000); - } else if (error.message.includes('Invalid coordinates')) { - console.log('📍 Invalid coordinate format:', error.message, 3000); - OpenToast('Invalid coordinate format. Check location data.', 'warning'), 3000; + OpenToast('Invalid coordinates or Google API error.', 'error', 3000); } else if (error.message.includes('Malformed Distance Matrix')) { - console.log('⚠️ Unexpected Google response structure:', error.message); OpenToast('Google Distance Matrix returned invalid data.', 'error', 3000); } else if (error.message.includes('Origin') || error.message.includes('customer')) { - console.log('❌ Missing or invalid input data:', error.message); OpenToast('Missing or invalid input data for distance calculation.', 'warning', 3000); } else { - console.log('💥 Unexpected error calculating distance:', error); OpenToast('Unexpected error during distance calculation.', 'error', 3000); } - - throw error; // keeps your current flow intact + throw error; } }; - // ==================================================== || fetchTiming || ==================================================== - const fetchTiming = async () => { - await axios - .get(`${process.env.REACT_APP_URL}/utils/getapplocations/?applocationid=${appId}`) - .then((res) => { - console.log('fetchTiming', res); - const { opentime, closetime } = res.data.details[0]; - if (res.data.status) { - console.log('starttime', `${dayjs().format('MM-DD-YYYY')} ${opentime}`); - console.log('endtime', `${dayjs().format('MM-DD-YYYY')} ${closetime} `); - let arr = []; - for ( - let i = `${dayjs().format('MM-DD-YYYY')} ${opentime}`; - dayjs(`${dayjs().format('MM-DD-YYYY')} ${closetime} `).diff(i, 'm') >= 0; - i = dayjs(i).add(30, 'm') - ) { - arr.push(i); - } - console.log('setTimeslotarr', arr); - setTimeslotarr(arr); - } - setLoading(false); - }) - .catch((err) => { - console.log(err); - setLoading(false); + // ============================== handleCheckboxChange (dialog: add/remove on tick) ============================== + const handleCheckboxChange = async (event, customer) => { + setLoading(true); + try { + if (event.target.checked) { + const { roundedDistance, totalcharge } = await calculateDistance(customer); + setDropCust((prev) => [...prev, { ...customer, distance: roundedDistance, totalcharge }]); + } else { + setDropCust((prev) => prev.filter((c) => c.customerid !== customer.customerid)); + } + } catch (err) { + console.error('Failed to calculate distance:', err); + } finally { + setLoading(false); + } + }; + + // Toggle handler used by: + // 1. CSV "Continue" bulk add (matches by firstname since uploaded rows + // don't carry a stable customerid). + // 2. Per-row remove (CloseOutlined) in the drop table. + // Uses a single functional updater + locally-captured already-selected flag + // so we don't read stale `dropCust` after the state change. + const handleCheckboxChange1 = async (customer) => { + // Compute "already selected" from the latest state synchronously. + let wasSelected = false; + setDropCust((prev) => { + wasSelected = prev.some((c) => c.firstname === customer.firstname); + if (wasSelected) { + return prev.filter((c) => c.firstname !== customer.firstname); + } + return prev; + }); + + if (wasSelected) return; // remove path is done + + // Add path — compute distance, then append. + setLoading(true); + try { + const { roundedDistance, totalcharge } = await calculateDistance(customer); + setDropCust((prev) => { + // Guard against parallel adds for the same row. + if (prev.some((c) => c.firstname === customer.firstname)) return prev; + return [...prev, { ...customer, distance: roundedDistance, totalcharge }]; }); - }; - useEffect(() => { - if (appId) { - fetchTiming(); + } catch (err) { + console.error('Failed to calculate distance:', err); + } finally { + setLoading(false); } - }, [appId]); + }; + // ============================== fetchAppAdminTokens ============================== const fetchAppAdminTokens = async () => { - setLoading(true); - await axios - .get(`${process.env.REACT_APP_URL}/utils/getapplocationconfig/?applocationid=${appId}`) - .then((res) => { - const userfcmtokemArray = res.data.details.applocationadmins.map((admin) => admin.userfcmtokem); // fcm => firebase cloud messaging - console.log('fetchAppAdminTokens', res); - console.log('userfcmtokemArray', userfcmtokemArray); - if (res.data.status) { - setAdmintoken(userfcmtokemArray); - } - setLoading(false); - }) - .catch((err) => { - console.log(err); - setLoading(false); - }); + try { + const res = await axios.get(`${process.env.REACT_APP_URL}/utils/getapplocationconfig/?applocationid=${appId}`); + if (res.data.status) { + const tokens = res.data.details.applocationadmins.map((a) => a.userfcmtokem); + setAdmintoken(tokens); + } + } catch (err) { + console.log(err); + } }; useEffect(() => { - if (appId) { - fetchAppAdminTokens(); - } + if (appId) fetchAppAdminTokens(); }, [appId]); - useEffect(() => { - console.log('pickCust', pickCust); - }, [pickCust]); - useEffect(() => { - console.log('dropCust', dropCust); - }, [dropCust]); - // ==================================================== || fetchtenantinfo || ==================================================== - const fetchtenantinfo = async () => { - setLoading(true); - console.log('tenantid', tenantid); - - await axios - .get(`${process.env.REACT_APP_URL}/tenants/gettenantinfo/?tenantid=${tenantid}`) - .then((res) => { - console.log('fetchtenantinfo', res); - if (res.data.status) { - setTenantid(res.data.details.tenantid); - } - setLoading(false); - }) - .catch((err) => { - console.log(err); - setLoading(false); - }); - }; - useEffect(() => { - if (tenantid) { - fetchtenantinfo(); - } - }, [tenantid]); - // ================================================== || sendnotifications || ================================================== + // ============================== sendnotifications ============================== const sendnotifications = async () => { - setLoading(true); - await axios - .post(`${process.env.REACT_APP_URL}/utils/sendnotifications`, { + try { + const res = await axios.post(`${process.env.REACT_APP_URL}/utils/sendnotifications`, { priority: 'high', registration_ids: admintoken, - data: { - accessid: process.env.REACT_APP_RIDER_ACCESS_ID - }, + data: { accessid: process.env.REACT_APP_RIDER_ACCESS_ID }, notification: { title: 'Nearle Merchant', - body: 'An Order has been placed successfully,kindly process the same', + body: 'An Order has been placed successfully, kindly process the same', sound: 'ring' } - }) - .then((res) => { - console.log(res); - if (res.data.message == 'Success') { - enqueueSnackbar('Notification sent Successfully', { - variant: 'success', - anchorOrigin: { vertical: 'top', horizontal: 'right' }, - autoHideDuration: 1000 - }); - } - setLoading(false); - }) - .catch((err) => { - console.log(err); - enqueueSnackbar(err.message, { - variant: 'error', - anchorOrigin: { vertical: 'top', horizontal: 'right' }, - autoHideDuration: 1000 - }); - setLoading(false); }); + if (res.data.message === 'Success') { + opentoast('Notification sent Successfully', 'success', 1000); + } + } catch (err) { + opentoast(err.message, 'error', 1000); + } }; - const cleanReceiverName = (name) => { - if (typeof name !== 'string') return name; - return name.replace(/^[\d.\s]+/, '').trim(); - }; + // ============================== CSV / XLSX upload ============================== + const cleanReceiverName = (name) => (typeof name === 'string' ? name.replace(/^[\d.\s]+/, '').trim() : name); + const normalizeHeader = (header) => header?.toString().trim().toLowerCase().replace(/\s+/g, ''); - - - // your header mapping const headerMap = { 'pickupdate(yyyy-mmm-dd)': 'date', 'sendername*': 'locationname', @@ -614,9 +458,6 @@ const MultipleOrders = () => { ' Collect Cash': 'collectionamt' }; - // helper to normalize headers - const normalizeHeader = (header) => header?.toString().trim().toLowerCase().replace(/\s+/g, ''); - const handleFileDirectUpload = (event) => { try { const file = event.target.files?.[0]; @@ -624,10 +465,9 @@ const MultipleOrders = () => { opentoast('No file selected.', 'warning'); return; } - - const fileName = file.name.toLowerCase(); - const isCSV = fileName.endsWith('.csv'); - const isExcel = fileName.endsWith('.xls') || fileName.endsWith('.xlsx'); + const fileNameLower = file.name.toLowerCase(); + const isCSV = fileNameLower.endsWith('.csv'); + const isExcel = fileNameLower.endsWith('.xls') || fileNameLower.endsWith('.xlsx'); if (!isCSV && !isExcel) { opentoast('Invalid file type. Please upload a CSV or Excel file.', 'warning'); @@ -635,12 +475,8 @@ const MultipleOrders = () => { } const processData = (data, headers) => { - console.log('data', data); const normalizedMap = {}; - for (const key in headerMap) { - normalizedMap[normalizeHeader(key)] = headerMap[key]; - } - console.log('normalizedMap', normalizedMap); + for (const key in headerMap) normalizedMap[normalizeHeader(key)] = headerMap[key]; const mappedData = data.map((row) => { const newRow = {}; @@ -649,26 +485,23 @@ const MultipleOrders = () => { const newKey = normalizedMap[cleanKey] || cleanKey; let value = row[key]; if (newKey === 'firstname') value = cleanReceiverName(value); - newRow[newKey] = value; } - return newRow; }); - const missingCols = Object.keys(headerMap).filter((clientCol) => !headers.includes(normalizeHeader(clientCol))); - + const missingCols = Object.keys(headerMap).filter( + (clientCol) => !headers.includes(normalizeHeader(clientCol)) + ); if (missingCols.length > 0) { - isExcel && opentoast(`Missing columns: ${missingCols.join(', ')}`, 'warning'); + opentoast(`Missing columns: ${missingCols.join(', ')}`, 'warning'); } - console.log('✅ Final Processed Data:', mappedData); setUsers(mappedData); - opentoast('File uploaded and successfully ', 'success', 3000); - opentoast('Press Continue', 'warning', 3000); + opentoast('File uploaded successfully', 'success', 2000); + opentoast('Press Continue to add as drop customers', 'info', 2500); }; - // ============ CSV handler ============ if (isCSV) { Papa.parse(file, { header: true, @@ -683,59 +516,92 @@ const MultipleOrders = () => { const headers = results.meta.fields.map(normalizeHeader); processData(results.data, headers); }, - error: (error) => { - console.error('❌ CSV Parsing Error:', error); - opentoast(`CSV parsing failed: ${error.message}`, 'warning'); - } + error: (error) => opentoast(`CSV parsing failed: ${error.message}`, 'warning') }); } - // ============ Excel handler ============ if (isExcel) { const reader = new FileReader(); reader.onload = (e) => { try { const data = e.target.result; - // Try reading as binary first - let workbook; - try { - workbook = XLSX.read(data, { type: 'binary' }); - } catch { - // fallback for modern XLSX files - const arrayBuffer = new Uint8Array(data); - workbook = XLSX.read(arrayBuffer, { type: 'array' }); - } - + const workbook = XLSX.read(data, { type: 'binary' }); const firstSheet = workbook.SheetNames[0]; const worksheet = workbook.Sheets[firstSheet]; const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: '' }); - if (!jsonData?.length) { opentoast('Excel file is empty or invalid.', 'warning'); setUsers([]); return; } - const headers = Object.keys(jsonData[0]).map(normalizeHeader); processData(jsonData, headers); } catch (err) { - console.error('❌ Error processing Excel:', err); opentoast(`Error reading Excel: ${err.message}`, 'warning'); } }; - - // Important: use readAsBinaryString for Excel reader.readAsBinaryString(file); } } catch (err) { - console.error('Unexpected error during file upload:', err); opentoast(`Unexpected error: ${err.message}`, 'warning'); } }; - // =============================================== || createorders || =============================================== + const removeFileExtension = (n) => n.replace(/\.[^/.]+$/, ''); + const onFileChange = (event) => { + const file = event.target.files[0]; + if (!file) return; + const cleanedName = removeFileExtension(file.name); + setFileName((prev) => (prev ? `${prev}, ${cleanedName}` : cleanedName)); + handleFileDirectUpload(event); + }; + + // ============================== row editors ============================== + const handleQuantityChange = (customerid, value) => { + setDropCust((prev) => prev.map((c) => (c.customerid === customerid ? { ...c, quantity: Number(value) || 0 } : c))); + }; + const handleCollectionAmtChange = (customerid, value) => { + setDropCust((prev) => prev.map((c) => (c.customerid === customerid ? { ...c, collectionamt: Number(value) || 0 } : c))); + }; + + // ============================== createorders ============================== + const buildDeliveryTime = () => { + // Prefer the parsed pickupSlot (already merged date + slot's time). + // Fall back to startdate + selectedtime when present. + if (pickupSlot) { + const parsed = dayjs(pickupSlot, ['YYYY-MM-DD hh:mm A', 'YYYY-MM-DD HH:mm:ss']); + if (parsed.isValid()) return parsed.format('YYYY-MM-DD HH:mm:ss'); + } + if (startdate && selectedtime) { + const parsed = dayjs(`${dayjs(startdate).format('YYYY-MM-DD')} ${selectedtime}`, [ + 'YYYY-MM-DD hh:mm A', + 'YYYY-MM-DD HH:mm:ss' + ]); + if (parsed.isValid()) return parsed.format('YYYY-MM-DD HH:mm:ss'); + } + return dayjs().format('YYYY-MM-DD HH:mm:ss'); + }; + const createorders = async () => { - // ===================== Build Payload ===================== + if (!tenantid) { + opentoast('Choose Client', 'warning'); + return; + } + if (!pickCust) { + opentoast('Pickup location required', 'warning'); + return; + } + if (!pickupSlot) { + opentoast('Select a pickup slot', 'warning'); + return; + } + if (!dropCust.length) { + opentoast('Add at least one drop customer', 'warning'); + return; + } + + const deliverytime = buildDeliveryTime(); + const arr = dropCust.map((customer) => ({ applocationid: pickCust.applocationid, configid: 9, @@ -766,7 +632,7 @@ const MultipleOrders = () => { deliverylocation: customer.suburb || '', deliverylocationid: customer.deliverylocationid || 0, deliverylong: customer.longitude?.toString() || '', - deliverytime: `${dayjs(startdate).format('YYYY-MM-DD')} ${dayjs(selectedtime.$d).format('HH:mm:ss')}`, + deliverytime, deliverytype: 'B', itemcount: 1, @@ -785,836 +651,1269 @@ const MultipleOrders = () => { pickupSlot })); - console.log('arr', arr); - - // ===================== Validation ===================== - if (!tenantid) { - opentoast('Choose Client', 'warning'); - return; - } setLoading(true); + setBtnLoading(true); try { const res = await axios.post(`${process.env.REACT_APP_URL}/orders/createorders`, arr); if (res.data.status) { - opentoast('Order Created Successfully', 'success', 2000); - if (admintoken) { - sendnotifications(); - } + opentoast('Orders Created Successfully', 'success', 2000); + if (admintoken) sendnotifications(); navigate('/nearle/orders'); - setLoading(false); } else { - console.log(res.data); - console.error('Create order failed (API response):', res.data); opentoast(res?.data?.message || 'Order creation failed. Please try again.', 'warning', 3000); } } catch (err) { - opentoast(err.message, 'error', 2000); - console.log('create orders', err.message); - console.error('Create order error:', { - message: err.message, - response: err.response, - request: err.request, - stack: err.stack - }); - - // Exact but short error for user let toastMessage = 'Something went wrong. Please try again.'; - if (err.response) { - // Server responded with error toastMessage = err.response.data?.message || `Server error (${err.response.status})`; } else if (err.request) { - // No response received toastMessage = 'Network error. Check your internet connection.'; } - opentoast(toastMessage, 'error'); - setLoading(false); + opentoast(toastMessage, 'error', 3000); } finally { setLoading(false); setBtnLoading(false); } }; - const [fileName, setFileName] = useState(''); - const removeFileExtension = (fileName) => { - return fileName.replace(/\.[^/.]+$/, ''); + // ============================== derived ============================== + const stepsComplete = { + location: !!appId, + client: !!tenantid, + business: !!locationid, + schedule: !!pickupSlot, + drops: dropCust.length > 0 }; + const canSubmit = + stepsComplete.location && stepsComplete.client && stepsComplete.business && stepsComplete.schedule && stepsComplete.drops; - const onFileChange = (event) => { - const file = event.target.files[0]; - if (!file) return; - const cleanedName = removeFileExtension(file.name); - setFileName((prev) => (prev ? `${prev}, ${cleanedName}` : cleanedName)); - if (tenantid === 916) { - handleFileDirectUpload(event); - } else { - handleFileDirectUpload(event); - // handleFileUpload(event); - } - }; - - const handleQuantityChange = (customerid, value) => { - setDropCust((prev) => prev.map((cust) => (cust.customerid === customerid ? { ...cust, quantity: Number(value) || 0 } : cust))); - }; - const handleCollectionAmtChange = (customerid, value) => { - setDropCust((prev) => prev.map((cust) => (cust.customerid === customerid ? { ...cust, collectionamt: Number(value) || 0 } : cust))); - }; + // ============================== preview helpers ============================== + // The right pane shows three exclusive views: + // 1. dropCust.length > 0 → "Drop List" with calculated charges + remove + // 2. users.length > 0 → raw "File Preview" of parsed rows, awaiting Continue + // 3. else → empty state + const previewMode = dropCust.length > 0 ? 'drops' : users.length > 0 ? 'preview' : 'empty'; + // ============================== render ============================== return ( <> - {loading && ( - <> - - {/* */} - - )} - { - theme.zIndex.drawer + 1 - }} - open={btnLoading} // when loader = true, backdrop covers the page - > - - - } + {loading && } + t.zIndex.drawer + 1 }} open={btnLoading}> + + - - - - Create Multiple Order - - - - - - {/* ===================================================== || Choose App location || ===================================================== */} - - `${option.locationname}`} - onChange={(event, value, reason) => { - if (reason === 'clear') { - setAppId(0); - setTenantid(0); - setTenantValue(null); - setTenantlist([]); - setLocationid(0); - setLocationValue(null); - setTenantlocations([]); - setPickCust(null); - setDropCust([]); - setUploadType(null); - } else { - setAppId(value.applocationid); - setDropCust([]); - setPickCust(null); - } - }} - renderInput={(params) => } - /> - - {/* ===================================================== || Choose client || ===================================================== */} - - option?.tenantname || ''} - isOptionEqualToValue={(option, value) => option.tenantid === value.tenantid} - renderOption={(props, option) => ( -
  • - {option.tenantname} -
  • - )} - onOpen={(event) => { - if (!appId) { - event.preventDefault(); - OpenToast('Please select your location first!', 'warning', 3000); - setTimeout(() => { - locationRef.current?.focus(); - }, 0); - } - }} - onChange={(e, val, reason) => { - if (reason === 'clear') { - setTenantid(0); - setTenantValue(null); - setLocationid(0); - setLocationValue(null); - setTenantlocations([]); - setPickCust(null); - setDropCust([]); - setUploadType(null); - } else if (val) { - setTenantid(val.tenantid); - setTenantValue(val); - setLocationid(0); - setLocationValue(null); - fetchTenantPricing(val.tenantid); - gettenantlocations(val.tenantid); - setDropCust([]); - } - }} - renderInput={(params) => } - /> -
    - {/* ===================================================== ||Business Location || ===================================================== */} - - {tenantLocations?.length == 1 ? ( - - - - ) - }} - /> - ) : ( + {/* Thin title bar */} + + + Create Multiple Orders + + + + + Bulk-create deliveries from CSV/Excel or saved customers. + + + + {/* ============================== 50 / 50 workspace ============================== */} + + {/* ============================== LEFT 50% : Input fields ============================== */} + + {/* Card: Setup (Location / Client / Business) */} + + + + Setup + + + + `${option.locationname} (${option.suburb})` || ''} - value={locationValue} - onOpen={(event) => { - if (!appId && !tenantid) { - event.preventDefault(); - OpenToast('Please select Location and Tenant first!', 'warning', 3000); - setTimeout(() => { - locationRef.current?.focus(); - }, 0); - } else if (!tenantid) { - event.preventDefault(); - OpenToast('Please select Tenant first!', 'warning', 3000); - setTimeout(() => { - tenantRef.current?.focus(); - }, 0); - } - }} + size="small" + ref={locationRef} + className="header-compact-input" + options={locations || []} + getOptionLabel={(option) => `${option.locationname}`} onChange={(event, value, reason) => { - if (reason === 'clear') { - setLocationid(0); - setLocationValue(null); - setPickCust(null); - } else { - setLocationid(value.locationid || 0); - setLocationValue(value); - setPickCust(value); - setPickupSlotsList(value?.slots); + if (reason === 'clear') setAppId(0); + else if (value) setAppId(value.applocationid); + }} + renderInput={(params) => ( + + + {params.InputProps.startAdornment} + + ) + }} + /> + )} + /> + + + option?.tenantname || ''} + isOptionEqualToValue={(option, value) => option.tenantid === value.tenantid} + onOpen={(event) => { + if (!appId) { + event.preventDefault(); + OpenToast('Please select Location first!', 'warning', 3000); + setTimeout(() => locationRef.current?.focus(), 0); } }} - renderInput={(params) => } + onChange={(e, val, reason) => { + if (reason === 'clear') { + setTenantid(0); + setTenantValue(null); + } else if (val) { + setTenantid(val.tenantid); + setTenantValue(val); + fetchTenantPricing(val.tenantid); + gettenantlocations(val.tenantid); + } + }} + renderInput={(params) => ( + + + {params.InputProps.startAdornment} + + ) + }} + /> + )} /> - )} + + + {tenantLocations.length === 1 ? ( + + ) + }} + /> + ) : ( + + option?.locationname ? `${option.locationname} (${option.suburb || ''})` : '' + } + onOpen={(event) => { + if (!appId && !tenantid) { + event.preventDefault(); + OpenToast('Please select Location and Client first!', 'warning', 3000); + setTimeout(() => locationRef.current?.focus(), 0); + } else if (!tenantid) { + event.preventDefault(); + OpenToast('Please select Client first!', 'warning', 3000); + setTimeout(() => tenantRef.current?.focus(), 0); + } + }} + onChange={(event, value, reason) => { + if (reason === 'clear' || !value) { + setLocationid(0); + setLocationValue(null); + setPickCust(null); + setPickupSlotsList(null); + } else { + setLocationid(value.locationid || 0); + setLocationValue(value); + setPickCust(value); + setPickupSlotsList(value?.slots); + } + }} + renderInput={(params) => ( + + + {params.InputProps.startAdornment} + + ) + }} + /> + )} + /> + )} + - -
    -
    - {/* ===================================================== || Pickup || ===================================================== */} - - - {locationid !== 0 && ( - - + + + {/* Card: Schedule + Pickup display */} + + + + Schedule & Pickup + + + + + + { + if (!e || !dayjs(e).isValid()) { + setStartdate(dayjs().format('MM-DD-YYYY')); + return; + } + const diffDays = dayjs().diff(dayjs(`${dayjs(e).format('YYYY-MM-DD')}`), 'd'); + if (diffDays <= 0) { + setStartdate(dayjs(e).format('MM-DD-YYYY')); + setSelectedtime(''); + setPickupSlot(null); + } else { + opentoast('Choose an upcoming date', 'warning'); + setStartdate(dayjs().format('MM-DD-YYYY')); + } + }} + disablePast + slotProps={{ + textField: { + size: 'small', + fullWidth: true, + InputLabelProps: { shrink: true }, + InputProps: { + startAdornment: ( + + + + ) + }, + sx: { + '& .MuiOutlinedInput-root': { + borderRadius: '10px', + height: '36px', + paddingLeft: '10px' + } + } + } + }} + /> + + + + { + if (reason === 'clear' || !newValue) { + setSelectedtime(null); + setPickupSlot(null); + return; + } + if (!newValue.time) { + OpenToast('This slot has no time configured.', 'warning', 3000); + return; + } + const formattedTime = dayjs(newValue.time, 'HH:mm').format('hh:mm A'); + setSelectedtime(formattedTime); + const finalDateTime = dayjs( + `${startdate} ${formattedTime}`, + 'MM-DD-YYYY hh:mm A' + ).format('YYYY-MM-DD hh:mm A'); + setPickupSlot(finalDateTime); + }} + getOptionLabel={(option) => + option ? `${option.name} (${dayjs(option.time, 'HH:mm').format('hh:mm A')})` : '' + } + renderInput={(params) => ( + + + {params.InputProps.startAdornment} + + ) + }} + /> + )} + /> + + + {/* Pickup display (compact, single row) */} + + {pickCust ? ( + + + + + + + {pickCust.locationname || '—'} + + + {pickCust.address || '—'} + + + + ) : ( + + Pickup auto-fills once a Business Location is selected. + + )} + + + + + + + {/* Card: Notes */} + + + Order Notes + Applied to every order + + + + setOtherinstructions(e.target.value)} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: '10px', + padding: '0 10px', + alignItems: 'center', + fontSize: '12px', + background: '#ffffff', + height: '32px' + }, + '& .MuiOutlinedInput-input': { + padding: '0 !important', + fontSize: '12px !important', + lineHeight: '32px' + } }} /> - - )} - {/* ================================================= || Time || ================================================= */} - {appId !== 0 && ( - - - - - { - setStartdate(e); - let dateres11 = dayjs().diff(dayjs(`${dayjs(e).format('YYYY-MM-DD')}`), 'd'); - console.log('dateres11'); - console.log(dateres11); - setSelectedtime(''); - if (dateres11 <= 0) { - console.log('startdate', e); - setStartdate(e); + + - let arr = []; - timeslotarr.map((val) => { - if (dayjs().diff(dayjs(`${dayjs(e).format('MM-DD-YYYY')} ${dayjs(val).format('HH:mm:ss')}`), 'm') <= 0) { - arr.push(val); - } - }); - } else { - setAlertmessage('choose Upcoming Date'); - opentoast('choose Upcoming Date', 'warning'); - setStartdate(NaN); - } - }} - value={dayjs(startdate)} - sx={{ width: 'auto', mt: 0 }} - disablePast - /> - - - {/* {timeslotarr.length > 0 && ( - - - - - - - Time - - - - - - { - setStartdate(e); - let dateres11 = dayjs().diff(dayjs(`${dayjs(e).format('YYYY-MM-DD')}`), 'd'); - console.log('dateres11'); - console.log(dateres11); - setSelectedtime(''); - if (dateres11 <= 0) { - console.log('startdate', e); - setStartdate(e); - - let arr = []; - timeslotarr.map((val) => { - if ( - dayjs().diff(dayjs(`${dayjs(e).format('MM-DD-YYYY')} ${dayjs(val).format('HH:mm:ss')}`), 'm') <= 0 - ) { - arr.push(val); - } - }); - } else { - setAlertmessage('choose Upcoming Date'); - opentoast('choose Upcoming Date', 'warning'); - setStartdate(NaN); - } - }} - value={dayjs(startdate)} - sx={{ width: 'auto', mt: 0 }} - disablePast - /> - - - - - - - {timeslotarr.map((val, index) => { - if ( - dayjs().diff(dayjs(`${dayjs(startdate).format('MM-DD-YYYY')} ${dayjs(val).format('HH:mm:ss')}`), 'm') <= 0 - ) { - return ( - - - { - console.log('selectedtime', val); - setSelectedtime(val); - }} - // onClick={() => { - // if (distance > appLocaRadius) { - // setOpen(true); - // } else if (showDistance) { - // console.log('selectedtime', val); - // setSelectedtime(val); - // } else { - // opentoast('Out of city limit', 'error'); - // } - // }} - /> - - - ); - } - })} - - - - - - )} */} - - - { - if (reason === 'clear') { - setSelectedtime(null); - setPickupSlot(null); - } else { - // Convert to AM/PM and merge with date - const formattedTime = dayjs(newValue.time, 'HH:mm').format('hh:mm A'); - setSelectedtime(formattedTime); - const finalDateTime = dayjs(`${startdate} ${formattedTime}`, 'MM-DD-YYYY hh:mm A').format('YYYY-MM-DD hh:mm A'); - setPickupSlot(finalDateTime); - } - }} - getOptionLabel={(option) => `${option.name} (${dayjs(option.time, 'HH:mm').format('hh:mm A')})`} - renderInput={(params) => } - /> - - - )} - - - - {/* ===================================================== || Drop || ===================================================== */} - - - - - Drop ({dropCust?.length || 0}) + {/* Card: Summary + Submit */} + + + + Bulk Summary - - {/* ================= Upload CSV ================= */} - {uploadType === 0 && ( - <> - {fileName && ( - - - - {fileName} - - - )} - - - - - - )} - - {/* ================= Continue ================= */} - {users.length >= 1 && uploadType === 0 && ( - - )} - - {/* ================= Select Customers ================= */} - {uploadType === 1 && ( - - )} - - {/* ================= Upload Type ================= */} - - - Upload Type - { - if (!appId || !tenantid || !locationid) { - OpenToast('Please select Location, Tenant, Business!', 'warning', 3000); - return; - } - setUploadType(Number(e.target.value)); - setDropCust([]); - setUsers([]); - setFileName(''); - }} - > - } label="Excel / CSV" /> - } label="Selection" /> - - - - - - } - > - - - {dropCust?.length > 0 ? ( - <> - - - S.No - Customer - Address - Quantity - - - Cash Collect - - Kms - - Charge - Action - - - - - {dropCust?.map((customer, index) => ( - - {index + 1} - {customer.firstname} - {customer.address} - - {uploadType == 0 ? ( - {customer.quantity} - ) : ( - handleQuantityChange(customer.customerid, e.target.value)} - inputProps={{ min: 0 }} - /> - )} - - - {uploadType == 0 ? ( - ₹{Number(customer.collectionamt || 0).toFixed(2)} - ) : ( - { - if (e.target.value <= 0) { - handleCollectionAmtChange(customer.customerid, 0); - } else { - handleCollectionAmtChange(customer.customerid, e.target.value); - } - }} - inputProps={{ min: 0 }} - InputProps={{ - startAdornment: - }} - /> - )} - - - {customer.distance} - {`₹${customer?.totalcharge?.toFixed(2)}`} - - { - <> - handleCheckboxChange(event, customer)} - onClick={() => handleCheckboxChange1(customer)} - /> - - } - - - ))} - {dropCust?.length != 0 && ( - - Total - - - - {`${totalQty} `} - - - - {`${totalCash?.toFixed(2)} `} - - - {`${totaldist} `} - - - {`₹${totalAmt?.toFixed(2)}`} - - - - - )} - - - ) : ( - + Live totals + + + {(() => { + const metric = ({ icon: Icon, label, value, accent, active }) => ( + - {/* Header */} - - {' '} - - Important Instructions + + + + + + {label} - - - {/* Ordered List */} - - - Choose either Upload Type to upload CSV/Excel files, or - Selection Type to select from saved customers. + + {value} + + + ); + return ( + + + {metric({ + icon: FaRoute, + label: 'Distance', + value: totaldist ? `${totaldist} km` : '—', + accent: '#1890ff', + active: !!totaldist + })} + + + {metric({ + icon: FaBoxes, + label: 'Quantity', + value: totalQty || 0, + accent: '#16a34a', + active: !!totalQty + })} + + + {metric({ + icon: FaMoneyBillWave, + label: 'Cash Collect', + value: `₹${Number(totalCash).toFixed(2)}`, + accent: '#d97706', + active: !!totalCash + })} + + + {metric({ + icon: FaTruck, + label: 'Deliveries', + value: dropCust.length, + accent: '#65387a', + active: dropCust.length > 0 + })} + + + ); + })()} - - Uploaded CSV or Excel files must follow the required format and contain the correct column names. - + {dropCust.length > 0 && ( +
    +
    + +
    Total Charge
    +
    +
    + ₹{Number(totalAmt).toFixed(2)} +
    +
    + )} - - Multiple files can be uploaded, but only one file at a time. - - - - Invalid or incorrectly formatted files will not be processed. - -
    - - )} -
    -
    -
    -
    - - - {/* ================================================= || Notes || ================================================= */} - - - - setOtherinstructions(e.target.value)} - /> - - + + + + + + {/* ============================== RIGHT 50% : File / Drop Preview ============================== */} + + + {/* Preview header (sticky inside card) */} + + + + {previewMode === 'drops' + ? `Drop List` + : previewMode === 'preview' + ? `File Preview` + : `Preview`} + + + + {previewMode === 'drops' && ( + + )} + {previewMode === 'preview' && ( + } + sx={{ + height: 22, + fontWeight: 700, + fontSize: 11, + bgcolor: 'rgba(245,158,11,0.12)', + color: '#b45309', + border: '1px solid rgba(245,158,11,0.30)', + '& .MuiChip-icon': { color: '#b45309' } + }} + /> + )} + {previewMode !== 'empty' && (() => { + const prereqOk = appId && tenantid && locationid; + const handleHeaderPick = (val) => { + if (!prereqOk) { + OpenToast('Please select Location, Client, and Business Location first.', 'warning', 3000); + return; + } + setUploadType(val); + if (val === 0) { + document.getElementById('upload-file')?.click(); + } else if (val === 1) { + setIsCustomerOpen(true); + setSearchCustList(''); + } + }; + return ( + + + + + ); + })()} + + + {fileName && ( + + + + {fileName} + + + )} + + {previewMode === 'preview' && users.length >= 1 && ( + - {btnLoading ? : 'Create'} - - - - - -
    - {/* ============================================= || saved address Dialog || ============================================= */} - { - setIsCustomerOpen(false); - }} - fullWidth - sx={{ minWidth: 'lg' }} - > - - - {`Select Drop Customers (${dropCust.length || 0})`} - - - setSearchCustList(e.target.value)} - sx={{ - '& .MuiOutlinedInput-input': { - p: '10.5px 0px 12px' - }, - bgcolor: 'white' - }} - startAdornment={ - - - - } - endAdornment={ - { - setSearchCustList(''); + + + Process & Calculate Distances + + + Click continue to import spreadsheet rows and calculate drop charges. + + + + + )} + + {/* Scrollable preview body */} + + {previewMode === 'drops' && ( + + + + + # + Customer + Address + + Qty + + + Cash + + Km + + Charge + + + {' '} + + + + + {dropCust.map((customer, index) => ( + + {index + 1} + + {customer.firstname} + + + + + {customer.address} + + + + + {uploadType === 0 ? ( + customer.quantity + ) : ( + handleQuantityChange(customer.customerid, e.target.value)} + inputProps={{ min: 0 }} + sx={{ width: 64, '& .MuiOutlinedInput-root': { borderRadius: '8px', height: 30 } }} + /> + )} + + + {uploadType === 0 ? ( + `₹${Number(customer.collectionamt || 0).toFixed(2)}` + ) : ( + { + const v = Number(e.target.value); + handleCollectionAmtChange(customer.customerid, v > 0 ? v : 0); + }} + inputProps={{ min: 0 }} + InputProps={{ + startAdornment: + }} + sx={{ width: 90, '& .MuiOutlinedInput-root': { borderRadius: '8px', height: 30 } }} + /> + )} + + {customer.distance} + + ₹{Number(customer?.totalcharge || 0).toFixed(2)} + + + + handleCheckboxChange1(customer)} + sx={{ color: '#ef4444', p: 0.5 }} + > + + + + + + ))} + + Total + + + {totalQty} + + + ₹{Number(totalCash).toFixed(2)} + + {totaldist} + + ₹{Number(totalAmt).toFixed(2)} + + + + +
    +
    + )} + + {previewMode === 'preview' && ( + + + + + # + Name + Contact + Address + + Qty + + + Cash + + + + + {users.map((u, i) => ( + + {i + 1} + + {u.firstname || '—'} + + {u.contactno || '—'} + + + + {u.address || '—'} + + + + + {u.quantity ?? '—'} + + + {u.collectionamt != null ? `₹${Number(u.collectionamt).toFixed(2)}` : '—'} + + + ))} + +
    +
    + )} + + {previewMode === 'empty' && (() => { + const prereqOk = appId && tenantid && locationid; + const handleEmptyPick = (val) => { + if (!prereqOk) { + OpenToast('Please select Location, Client, and Business Location first.', 'warning', 3000); + return; + } + setUploadType(val); + setDropCust([]); + setUsers([]); + setFileName(''); + if (val === 0) { + document.getElementById('upload-file')?.click(); + } else if (val === 1) { + setIsCustomerOpen(true); + setSearchCustList(''); + } + }; + + const tile = ({ value, icon: Icon, title, sub, accent }) => { + return ( + handleEmptyPick(value)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleEmptyPick(value); + } + }} + sx={{ + flex: 1, + minWidth: 0, + display: 'flex', + alignItems: 'center', + textAlign: 'left', + gap: 1.25, + px: 1.5, + py: 1.25, + borderRadius: '10px', + border: `1.5px solid #eef2f6`, + bgcolor: '#fff', + opacity: prereqOk ? 1 : 0.6, + cursor: prereqOk ? 'pointer' : 'not-allowed', + transition: 'all 0.18s ease', + '&:hover': prereqOk + ? { + borderColor: accent, + boxShadow: `0 4px 12px -4px ${accent}40`, + transform: 'translateY(-1px)' + } + : undefined + }} + > + + + + + + {title} + + + {sub} + + + + ); + }; + + return ( + - -
    - } - autoComplete="off" - /> -
    + + + + + Choose a Drop Source to begin + + + Select a source from below or the left panel to import or pick your delivery customers. + + + + {tile({ + value: 0, + icon: MdOutlineCloudUpload, + title: 'Excel / CSV', + sub: 'Bulk upload a sheet', + accent: '#1890ff' + })} + {tile({ + value: 1, + icon: FaUsers, + title: 'Selection', + sub: 'Pick saved customers', + accent: '#65387a' + })} + +
    + ); + })()} + + + + + + + + + {/* ============================== Saved customers dialog ============================== */} + setIsCustomerOpen(false)} + fullWidth + sx={{ + '& .MuiDialog-paper': { + borderRadius: '16px', + overflow: 'hidden' + } + }} + > + + + + {`Select Drop Customers (${dropCust.length || 0})`} + + + setSearchCustList(e.target.value)} + sx={{ + bgcolor: 'white', + borderRadius: '10px', + '& .MuiOutlinedInput-input': { p: '10px 14px' } + }} + startAdornment={ + + + + } + endAdornment={ + setSearchCustList('')} + > + + + } + autoComplete="off" + /> - - {customerlist?.length == 0 ? ( - - + + {customerlist?.length === 0 ? ( + + ) : ( - - {customerlist && - customerlist?.map((customer, index) => ( - + + {customerlist?.map((customer, index) => { + const checked = dropCust.some((c) => c.customerid === customer.customerid); + return ( + cust.customerid === customer.customerid)} // Set the checked state of the checkbox based on whether the customer is in `dropCust` + checked={checked} onChange={(event) => handleCheckboxChange(event, customer)} /> } label={ -
    - - {`${customer.firstname} (${customer.contactno})`} + + + {customer.firstname} ({customer.contactno}) - - + {customer.address} -
    + } />
    - ))} + ); + })}
    )}
    - +
    diff --git a/src/pages/nearle/orders/orders.js b/src/pages/nearle/orders/orders.js index d7ddc23..4fd0d08 100644 --- a/src/pages/nearle/orders/orders.js +++ b/src/pages/nearle/orders/orders.js @@ -142,6 +142,10 @@ const Orders = () => { const [finaldeliveryList, setFinalDeliveryList] = useState([]); const aiModeRef = useRef(0); + // Caches the inputs of the most recent AI Assign call so we can forward them + // to the /nearle/dispatch/preview page via navigate state (the page re-uses + // them for its Re-Assign button). + const aiMutationContextRef = useRef(null); const rowsPerPage = 100; const transportOptions = [ @@ -417,19 +421,25 @@ const Orders = () => { setZoneData(data?.zones); setMetaData(data?.meta); setDispatchPreviewData(data); - setAiDialog(true); - // navigate('/nearle/orders/optimisedpreview', { - // state: { - // zoneSummary: data?.zone_analysis, - // deliverylist: data?.details, // to deliveryDetails - // zoneData: data?.zones, - // metaData: data?.meta, - // riderToken: rider.userfcmtoken, - // appId, - // aiMode: aiModeRef.current, - // reassignOrders - // } - // }); + // Route the AI Assign result to the dedicated Preview page (Dispatch + // view + Reconcile tab + Change Rider). The previous in-page dialog + // (aiDialog) is left intact but no longer opened — the page replaces + // it. All inputs needed for Re-Assign / Assign Orders are forwarded. + const ctx = aiMutationContextRef.current || {}; + navigate('/nearle/dispatch/preview', { + state: { + dispatchPreviewData: data, + aiMode: ctx.aiMode ?? aiModeRef.current, + selectedMode: ctx.selectedMode || selectedMode, + deliveryData: ctx.deliveryData || [], + autoRiders: ctx.autoRiders || autoRiders || [], + absentRidersPayload: ctx.absentRidersPayload || [], + rider: ctx.rider || rider, + appId: ctx.appId ?? appId, + tenantId: ctx.tenantid ?? tenantid, + startdate + } + }); } }, onError: (error) => { @@ -562,6 +572,19 @@ const Orders = () => { `Rider ${r.userid}` })); + // Remember the inputs so the Preview page can re-run Re-Assign with the + // same payload without us having to re-derive it from current Orders state. + aiMutationContextRef.current = { + deliveryData, + absentRidersPayload, + autoRiders, + selectedMode, + aiMode: aiModeRef.current, + rider, + appId, + tenantid + }; + if (aiModeRef.current == 0) { // manual assign createDeliveryMutation.mutate({ diff --git a/src/routes/MainRoutes.js b/src/routes/MainRoutes.js index ed25929..276957b 100644 --- a/src/routes/MainRoutes.js +++ b/src/routes/MainRoutes.js @@ -49,6 +49,7 @@ const Riders = Loadable(lazy(() => import('pages/nearle/riders/riders'))); const Createrider = Loadable(lazy(() => import('pages/nearle/riders/createrider'))); const EditRider = Loadable(lazy(() => import('pages/nearle/riders/editRider'))); const Dispatch = Loadable(lazy(() => import('pages/nearle/dispatch/Dispatch'))); +const DispatchPreview = Loadable(lazy(() => import('pages/nearle/dispatch/Preview'))); // ==============================|| MAIN ROUTING ||============================== // @@ -170,6 +171,10 @@ const MainRoutes = { { path: 'dispatch', element: + }, + { + path: 'dispatch/preview', + element: } ] },