From ec222346dff5cf9cfe306f2f9f2cf947961edb34 Mon Sep 17 00:00:00 2001 From: Guritfak Gill Date: Sat, 18 Jul 2026 17:20:07 -0700 Subject: [PATCH 1/6] fix(edit): allow partial optimization for over-capacity routes Let route managers continue with a clearly labeled warning while VROOM selects the feasible deliveries. Co-authored-by: Cursor --- .../edit/components/shared/ErrorOverlay.tsx | 49 +++++++++++++++---- app/ui/src/app/edit/edit.module.css | 1 + app/ui/src/app/edit/formStyles.v2.ts | 5 +- app/ui/src/app/edit/hooks/useOptimize.ts | 16 ++++-- app/ui/src/app/edit/page.tsx | 33 +++++++++++-- 5 files changed, 86 insertions(+), 18 deletions(-) diff --git a/app/ui/src/app/edit/components/shared/ErrorOverlay.tsx b/app/ui/src/app/edit/components/shared/ErrorOverlay.tsx index aedeefbf..427c4145 100644 --- a/app/ui/src/app/edit/components/shared/ErrorOverlay.tsx +++ b/app/ui/src/app/edit/components/shared/ErrorOverlay.tsx @@ -1,14 +1,18 @@ "use client"; +import { useId } from "react"; + import { ERROR_OVERLAY_FOOTER, ERROR_OVERLAY_MESSAGE, OVERLAY_BACKDROP, + OVERLAY_CANCEL_BTN, OVERLAY_CLOSE_BTN, OVERLAY_HEADER, OVERLAY_PANEL, OVERLAY_PRIMARY_BTN, OVERLAY_TITLE, + OVERLAY_WARNING_BTN, } from "@/app/edit/formStyles.v2"; import styles from "@/app/edit/edit.module.css"; import { useFocusTrap } from "@/app/edit/hooks/useFocusTrap"; @@ -16,10 +20,23 @@ import { useFocusTrap } from "@/app/edit/hooks/useFocusTrap"; type ErrorOverlayProps = { message: string | null; onClose: () => void; + title?: string; + action?: { + label: string; + onClick: () => void; + tone?: "warning"; + }; }; -export default function ErrorOverlay({ message, onClose }: ErrorOverlayProps) { +export default function ErrorOverlay({ + message, + onClose, + title = "Something went wrong", + action, +}: ErrorOverlayProps) { const panelRef = useFocusTrap(!!message); + const titleId = useId(); + const messageId = useId(); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { @@ -34,19 +51,20 @@ export default function ErrorOverlay({ message, onClose }: ErrorOverlayProps) {
-

- Something went wrong +

+ {title}

-

{message}

+

+ {message} +

+ {action && ( + + )}
diff --git a/app/ui/src/app/edit/edit.module.css b/app/ui/src/app/edit/edit.module.css index e01d0fbd..97443634 100644 --- a/app/ui/src/app/edit/edit.module.css +++ b/app/ui/src/app/edit/edit.module.css @@ -8,6 +8,7 @@ */ .root { + --edit-btn-warning: #F2B134; } /* ── Primary Button Hover and Pressed States Overlay ──────────────────────── */ diff --git a/app/ui/src/app/edit/formStyles.v2.ts b/app/ui/src/app/edit/formStyles.v2.ts index c0892095..29c7891b 100644 --- a/app/ui/src/app/edit/formStyles.v2.ts +++ b/app/ui/src/app/edit/formStyles.v2.ts @@ -556,6 +556,9 @@ export const OVERLAY_CANCEL_BTN = export const OVERLAY_PRIMARY_BTN = "h-9 px-4 rounded-[80px] bg-[var(--edit-btn-primary)] font-semibold text-[14px] leading-5 text-[var(--edit-text-primary)] whitespace-nowrap cursor-pointer"; +export const OVERLAY_WARNING_BTN = + "h-9 px-4 rounded-[80px] bg-[var(--edit-btn-warning)] font-semibold text-[14px] leading-5 text-[var(--edit-text-primary)] whitespace-nowrap cursor-pointer"; + export const OVERLAY_DELETE_BTN = "h-9 px-4 rounded-[80px] bg-[var(--edit-btn-delete)] font-semibold text-[14px] leading-5 text-[var(--edit-text-invert)] whitespace-nowrap cursor-pointer"; @@ -785,7 +788,7 @@ export const MOBILE_BOTTOM_BAR_SECONDARY_LABEL = export const ERROR_OVERLAY_MESSAGE = "text-[14px] leading-5 text-[var(--edit-text-secondary)] w-full"; -export const ERROR_OVERLAY_FOOTER = "flex items-center justify-end"; +export const ERROR_OVERLAY_FOOTER = "flex items-center justify-end gap-2"; // ── CSV Upload Overlay (Figma 8102:1548 desktop / 7472:5765 mobile) ─────────── diff --git a/app/ui/src/app/edit/hooks/useOptimize.ts b/app/ui/src/app/edit/hooks/useOptimize.ts index 9310fae2..f7c5422e 100644 --- a/app/ui/src/app/edit/hooks/useOptimize.ts +++ b/app/ui/src/app/edit/hooks/useOptimize.ts @@ -102,6 +102,7 @@ export function useOptimize( const router = useRouter(); const [isOptimizing, setIsOptimizing] = useState(false); const [optimizeError, setOptimizeError] = useState(null); + const [capacityWarning, setCapacityWarning] = useState(null); const [geocodeFailedAddressIds, setGeocodeFailedAddressIds] = useState< number[] >([]); @@ -121,8 +122,9 @@ export function useOptimize( const [needsDepotAddress, setNeedsDepotAddress] = useState(false); const optimize = useCallback( - async (depotAddress?: string) => { + async (depotAddress?: string, allowPartialOptimization = false) => { setOptimizeError(null); + setCapacityWarning(null); setGeocodeFailedAddressIds([]); setGeocodeFailedVehicleIds([]); setOutOfRegionAddressIds([]); @@ -175,9 +177,9 @@ export function useOptimize( (sum, v) => sum + v.capacity, 0, ); - if (totalDemand > totalCapacity) { - setOptimizeError( - `Total delivery quantity (${totalDemand}) exceeds total vehicle capacity (${totalCapacity}). Add more vehicles or reduce quantities.`, + if (totalDemand > totalCapacity && !allowPartialOptimization) { + setCapacityWarning( + `Total delivery quantity (${totalDemand}) exceeds total vehicle capacity (${totalCapacity}). Optimize anyway to create a partial route; deliveries that cannot fit within the available capacity will remain unassigned.`, ); return; } @@ -422,6 +424,10 @@ export function useOptimize( setOptimizeError(null); }, []); + const clearCapacityWarning = useCallback(() => { + setCapacityWarning(null); + }, []); + const dismissDepotAddressPrompt = useCallback(() => { setNeedsDepotAddress(false); }, []); @@ -431,6 +437,8 @@ export function useOptimize( isOptimizing, optimizeError, clearOptimizeError, + capacityWarning, + clearCapacityWarning, needsDepotAddress, dismissDepotAddressPrompt, geocodeFailedAddressIds, diff --git a/app/ui/src/app/edit/page.tsx b/app/ui/src/app/edit/page.tsx index 4665629d..5f3d1bb0 100644 --- a/app/ui/src/app/edit/page.tsx +++ b/app/ui/src/app/edit/page.tsx @@ -71,6 +71,8 @@ export default function Page() { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isUploadOverlayOpen, setIsUploadOverlayOpen] = useState(false); const [pendingCSVFile, setPendingCSVFile] = useState(null); + const [allowPartialOptimization, setAllowPartialOptimization] = + useState(false); const { importVehicles } = vehicleState; const { importAddresses } = addressState; const { parseError, closeImportModal } = useCSVImport(); @@ -80,6 +82,8 @@ export default function Page() { isOptimizing, optimizeError, clearOptimizeError, + capacityWarning, + clearCapacityWarning, needsDepotAddress, dismissDepotAddressPrompt, geocodeFailedAddressIds, @@ -224,15 +228,25 @@ export default function Page() { const clearSessionError = useCallback(() => setSessionError(null), []); + const dismissCapacityWarning = useCallback(() => { + setAllowPartialOptimization(false); + clearCapacityWarning(); + }, [clearCapacityWarning]); + + const handleOptimizeAnyway = useCallback(() => { + setAllowPartialOptimization(true); + void optimize(undefined, true); + }, [optimize]); + const handleStartLocationSave = useCallback( (addr: LocationAddress) => { const parts = [addr.line1]; if (addr.line2.trim()) parts.push(addr.line2); parts.push(addr.city, `${addr.state} ${addr.zipCode}`, addr.country); const formattedAddress = parts.join(", "); - void optimize(formattedAddress); + void optimize(formattedAddress, allowPartialOptimization); }, - [optimize], + [allowPartialOptimization, optimize], ); function handlePageDragEnter(e: React.DragEvent) { @@ -290,6 +304,16 @@ export default function Page() { )} + { + setAllowPartialOptimization(false); + dismissDepotAddressPrompt(); + }} onSave={handleStartLocationSave} /> )} From 71bac9677c5b3545ff8d79946a821d10681ecd7f Mon Sep 17 00:00:00 2001 From: Guritfak Gill Date: Sat, 18 Jul 2026 17:27:37 -0700 Subject: [PATCH 2/6] style(edit): format warning color token Keep the new warning token aligned with the frontend formatter output. Co-authored-by: Cursor --- app/ui/src/app/edit/edit.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui/src/app/edit/edit.module.css b/app/ui/src/app/edit/edit.module.css index 97443634..195b2814 100644 --- a/app/ui/src/app/edit/edit.module.css +++ b/app/ui/src/app/edit/edit.module.css @@ -8,7 +8,7 @@ */ .root { - --edit-btn-warning: #F2B134; + --edit-btn-warning: #f2b134; } /* ── Primary Button Hover and Pressed States Overlay ──────────────────────── */ From 6ec79abd7e9bdb931b5b882e2f6b0669f3149bae Mon Sep 17 00:00:00 2001 From: Guritfak Gill Date: Sat, 18 Jul 2026 21:54:13 -0700 Subject: [PATCH 3/6] refactor(edit): rename error overlay to alert popup Make error and warning intent explicit without duplicating dialog behavior. Co-authored-by: Cursor --- .../components/address/CSVUploadOverlay.tsx | 4 +-- .../{ErrorOverlay.tsx => AlertPopup.tsx} | 33 ++++++++++--------- app/ui/src/app/edit/formStyles.v2.ts | 6 ++-- app/ui/src/app/edit/page.tsx | 14 ++++---- 4 files changed, 30 insertions(+), 27 deletions(-) rename app/ui/src/app/edit/components/shared/{ErrorOverlay.tsx => AlertPopup.tsx} (75%) diff --git a/app/ui/src/app/edit/components/address/CSVUploadOverlay.tsx b/app/ui/src/app/edit/components/address/CSVUploadOverlay.tsx index 519bbafc..5fa44b13 100644 --- a/app/ui/src/app/edit/components/address/CSVUploadOverlay.tsx +++ b/app/ui/src/app/edit/components/address/CSVUploadOverlay.tsx @@ -27,7 +27,7 @@ import { CSV_UPLOAD_FILE_CHIP_SIZE, CSV_UPLOAD_FILE_CHIP_REMOVE, } from "@/app/edit/formStyles.v2"; -import ErrorOverlay from "@/app/edit/components/shared/ErrorOverlay"; +import AlertPopup from "@/app/edit/components/shared/AlertPopup"; import type { AddressCard } from "@/app/edit/types/delivery"; import { useCSVImport } from "@/app/edit/hooks/useCSVImport"; @@ -666,7 +666,7 @@ export default function CSVUploadOverlay({ // ── Render ────────────────────────────────────────────────────────────────── if (parseError) { - return ; + return ; } if (isImportModalOpen) { diff --git a/app/ui/src/app/edit/components/shared/ErrorOverlay.tsx b/app/ui/src/app/edit/components/shared/AlertPopup.tsx similarity index 75% rename from app/ui/src/app/edit/components/shared/ErrorOverlay.tsx rename to app/ui/src/app/edit/components/shared/AlertPopup.tsx index 427c4145..c6185088 100644 --- a/app/ui/src/app/edit/components/shared/ErrorOverlay.tsx +++ b/app/ui/src/app/edit/components/shared/AlertPopup.tsx @@ -1,10 +1,10 @@ "use client"; -import { useId } from "react"; +import { type KeyboardEvent, useId } from "react"; import { - ERROR_OVERLAY_FOOTER, - ERROR_OVERLAY_MESSAGE, + ALERT_POPUP_FOOTER, + ALERT_POPUP_MESSAGE, OVERLAY_BACKDROP, OVERLAY_CANCEL_BTN, OVERLAY_CLOSE_BTN, @@ -17,32 +17,35 @@ import { import styles from "@/app/edit/edit.module.css"; import { useFocusTrap } from "@/app/edit/hooks/useFocusTrap"; -type ErrorOverlayProps = { +export type AlertPopupVariant = "error" | "warning"; + +type AlertPopupProps = { message: string | null; onClose: () => void; + variant?: AlertPopupVariant; title?: string; action?: { label: string; onClick: () => void; - tone?: "warning"; }; }; -export default function ErrorOverlay({ +export default function AlertPopup({ message, onClose, - title = "Something went wrong", + variant = "error", + title = variant === "warning" ? "Warning" : "Something went wrong", action, -}: ErrorOverlayProps) { +}: AlertPopupProps) { const panelRef = useFocusTrap(!!message); const titleId = useId(); const messageId = useId(); - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { + function handleKeyDown(event: KeyboardEvent): void { + if (event.key === "Escape") { onClose(); } - }; + } if (!message) return null; @@ -74,17 +77,17 @@ export default function ErrorOverlay({ stroke="currentColor" strokeWidth="2" strokeLinecap="round" - aria-hidden="true" + aria-hidden={true} > -

+

{message}

-
+
{action && ( diff --git a/app/ui/src/app/edit/formStyles.v2.ts b/app/ui/src/app/edit/formStyles.v2.ts index 29c7891b..5d377258 100644 --- a/app/ui/src/app/edit/formStyles.v2.ts +++ b/app/ui/src/app/edit/formStyles.v2.ts @@ -783,12 +783,12 @@ export const MOBILE_BOTTOM_BAR_SECONDARY_BTN = export const MOBILE_BOTTOM_BAR_SECONDARY_LABEL = "font-['Manrope',sans-serif] font-semibold text-[16px] leading-[22px] text-[var(--edit-text-primary)] whitespace-nowrap"; -// ── ErrorOverlay ────────────────────────────────────────────────────────────── +// ── AlertPopup ──────────────────────────────────────────────────────────────── -export const ERROR_OVERLAY_MESSAGE = +export const ALERT_POPUP_MESSAGE = "text-[14px] leading-5 text-[var(--edit-text-secondary)] w-full"; -export const ERROR_OVERLAY_FOOTER = "flex items-center justify-end gap-2"; +export const ALERT_POPUP_FOOTER = "flex items-center justify-end gap-2"; // ── CSV Upload Overlay (Figma 8102:1548 desktop / 7472:5765 mobile) ─────────── diff --git a/app/ui/src/app/edit/page.tsx b/app/ui/src/app/edit/page.tsx index 5f3d1bb0..a8067180 100644 --- a/app/ui/src/app/edit/page.tsx +++ b/app/ui/src/app/edit/page.tsx @@ -10,7 +10,7 @@ import Navbar from "@/app/components/navbar/Navbar"; import MobileNavbar from "@/app/components/navbar/MobileNavbar"; import MobileSidebar from "@/app/components/sidebar/MobileSidebar"; import OptimizingModal from "@/app/edit/components/shared/OptimizingModal"; -import ErrorOverlay from "@/app/edit/components/shared/ErrorOverlay"; +import AlertPopup from "@/app/edit/components/shared/AlertPopup"; import Sidebar from "@/app/components/sidebar/Sidebar"; import SidebarEditButton from "@/app/components/sidebar/SidebarEditButton"; import SidebarResultsButton from "@/app/components/sidebar/SidebarResultsButton"; @@ -303,23 +303,23 @@ export default function Page() { /> )} - - + - - + setUploadError(null)} /> - + {needsDepotAddress && ( Date: Sat, 18 Jul 2026 21:57:03 -0700 Subject: [PATCH 4/6] style(edit): format alert popup usage Keep the edit page aligned with the frontend formatter output. Co-authored-by: Cursor --- app/ui/src/app/edit/page.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/ui/src/app/edit/page.tsx b/app/ui/src/app/edit/page.tsx index a8067180..d4c27042 100644 --- a/app/ui/src/app/edit/page.tsx +++ b/app/ui/src/app/edit/page.tsx @@ -315,10 +315,7 @@ export default function Page() { }} /> - setUploadError(null)} - /> + setUploadError(null)} /> {needsDepotAddress && ( From f27595e3698fdfd91d98a076790de74bc6783967 Mon Sep 17 00:00:00 2001 From: Guritfak Gill Date: Sat, 25 Jul 2026 14:34:07 -0700 Subject: [PATCH 5/6] fix(edit): harden partial-optimization approval lifecycle Keep Optimize Anyway approval in the optimize hook for a single attempt, open the depot overlay without starting the solver, and clear stale approval on dismiss or failed runs. Co-authored-by: Cursor --- .../components/address/AddressOverlay.tsx | 7 + .../app/edit/components/shared/AlertPopup.tsx | 4 + app/ui/src/app/edit/edit.module.css | 2 +- app/ui/src/app/edit/formStyles.v2.ts | 4 +- app/ui/src/app/edit/hooks/useOptimize.ts | 170 ++++++++++++++---- .../edit/lib/partialOptimizationApproval.ts | 34 ++++ app/ui/src/app/edit/page.tsx | 33 ++-- app/ui/src/app/globals.css | 1 + .../tests/partialOptimizationApproval.test.ts | 48 +++++ 9 files changed, 245 insertions(+), 58 deletions(-) create mode 100644 app/ui/src/app/edit/lib/partialOptimizationApproval.ts create mode 100644 app/ui/src/tests/partialOptimizationApproval.test.ts diff --git a/app/ui/src/app/edit/components/address/AddressOverlay.tsx b/app/ui/src/app/edit/components/address/AddressOverlay.tsx index 0f769bc5..24ed212c 100644 --- a/app/ui/src/app/edit/components/address/AddressOverlay.tsx +++ b/app/ui/src/app/edit/components/address/AddressOverlay.tsx @@ -91,6 +91,7 @@ type AddressOverlayProps = { initialAddress?: Partial; onClose: () => void; onSave: (address: LocationAddress) => void; + primaryDisabled?: boolean; }; export default function AddressOverlay({ @@ -99,6 +100,7 @@ export default function AddressOverlay({ initialAddress, onClose, onSave, + primaryDisabled = false, }: AddressOverlayProps) { const panelRef = useFocusTrap(true); @@ -109,6 +111,7 @@ export default function AddressOverlay({ const [zipCode, setZipCode] = useState(initialAddress?.zipCode ?? ""); const [country, setCountry] = useState(initialAddress?.country ?? ""); const [submitted, setSubmitted] = useState(false); + const [saveStarted, setSaveStarted] = useState(false); const line1InputRef = useRef(null); const blurTimeoutRef = useRef | null>(null); @@ -177,6 +180,7 @@ export default function AddressOverlay({ } function handleSave() { + if (saveStarted || primaryDisabled) return; setSubmitted(true); const trimmedLine1 = line1.trim(); const trimmedCity = city.trim(); @@ -188,6 +192,7 @@ export default function AddressOverlay({ !country ) return; + setSaveStarted(true); onSave({ line1: trimmedLine1, line2: line2.trim(), @@ -442,6 +447,8 @@ export default function AddressOverlay({ type="button" onClick={handleSave} className={`${OVERLAY_PRIMARY_BTN} ${styles.primaryBtnOverlay}`} + disabled={primaryDisabled || saveStarted} + aria-busy={primaryDisabled || saveStarted} > {primaryLabel} diff --git a/app/ui/src/app/edit/components/shared/AlertPopup.tsx b/app/ui/src/app/edit/components/shared/AlertPopup.tsx index c6185088..8ef38f9f 100644 --- a/app/ui/src/app/edit/components/shared/AlertPopup.tsx +++ b/app/ui/src/app/edit/components/shared/AlertPopup.tsx @@ -28,6 +28,7 @@ type AlertPopupProps = { label: string; onClick: () => void; }; + actionDisabled?: boolean; }; export default function AlertPopup({ @@ -36,6 +37,7 @@ export default function AlertPopup({ variant = "error", title = variant === "warning" ? "Warning" : "Something went wrong", action, + actionDisabled = false, }: AlertPopupProps) { const panelRef = useFocusTrap(!!message); const titleId = useId(); @@ -101,6 +103,8 @@ export default function AlertPopup({ type="button" onClick={action?.onClick ?? onClose} className={`${variant === "warning" ? OVERLAY_WARNING_BTN : OVERLAY_PRIMARY_BTN} ${styles.primaryBtnOverlay}`} + disabled={action ? actionDisabled : false} + aria-busy={action ? actionDisabled : false} > {action?.label ?? "Dismiss"} diff --git a/app/ui/src/app/edit/edit.module.css b/app/ui/src/app/edit/edit.module.css index 195b2814..213d1d11 100644 --- a/app/ui/src/app/edit/edit.module.css +++ b/app/ui/src/app/edit/edit.module.css @@ -7,8 +7,8 @@ *
...
*/ +/* Page wrapper class; --edit-* button tokens live on :root in globals.css. */ .root { - --edit-btn-warning: #f2b134; } /* ── Primary Button Hover and Pressed States Overlay ──────────────────────── */ diff --git a/app/ui/src/app/edit/formStyles.v2.ts b/app/ui/src/app/edit/formStyles.v2.ts index 5d377258..409fea89 100644 --- a/app/ui/src/app/edit/formStyles.v2.ts +++ b/app/ui/src/app/edit/formStyles.v2.ts @@ -554,10 +554,10 @@ export const OVERLAY_CANCEL_BTN = "h-9 px-4 rounded-[80px] font-semibold text-[14px] leading-5 text-[var(--edit-text-primary)] whitespace-nowrap hover:bg-[var(--edit-tertiary-btn-hover)] active:bg-[var(--edit-tertiary-btn-pressed)] transition-colors cursor-pointer"; export const OVERLAY_PRIMARY_BTN = - "h-9 px-4 rounded-[80px] bg-[var(--edit-btn-primary)] font-semibold text-[14px] leading-5 text-[var(--edit-text-primary)] whitespace-nowrap cursor-pointer"; + "h-9 px-4 rounded-[80px] bg-[var(--edit-btn-primary)] font-semibold text-[14px] leading-5 text-[var(--edit-text-primary)] whitespace-nowrap cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"; export const OVERLAY_WARNING_BTN = - "h-9 px-4 rounded-[80px] bg-[var(--edit-btn-warning)] font-semibold text-[14px] leading-5 text-[var(--edit-text-primary)] whitespace-nowrap cursor-pointer"; + "h-9 px-4 rounded-[80px] bg-[var(--edit-btn-warning)] font-semibold text-[14px] leading-5 text-[var(--edit-text-primary)] whitespace-nowrap cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"; export const OVERLAY_DELETE_BTN = "h-9 px-4 rounded-[80px] bg-[var(--edit-btn-delete)] font-semibold text-[14px] leading-5 text-[var(--edit-text-invert)] whitespace-nowrap cursor-pointer"; diff --git a/app/ui/src/app/edit/hooks/useOptimize.ts b/app/ui/src/app/edit/hooks/useOptimize.ts index f7c5422e..c6350e14 100644 --- a/app/ui/src/app/edit/hooks/useOptimize.ts +++ b/app/ui/src/app/edit/hooks/useOptimize.ts @@ -3,7 +3,7 @@ * job, poll status, then fetch the completed result. */ -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { geocodeAddress } from "@/app/components/AddressGeocoder/utils/nominatim"; @@ -16,6 +16,12 @@ import { SUPPORTED_STATES } from "@/app/edit/constants/supportedRegions"; import { hasCachedLocationWithState } from "@/app/edit/utils/deliveryHelpers"; import { setOptimizeResults } from "@/app/edit/utils/hasOptimizeResults"; import { vroomToRoutes } from "@/app/edit/utils/vroomToRoutes"; +import { + capacityWarningMessage, + nextPartialApproval, + shouldWarnForOverCapacity, + type PartialApprovalEvent, +} from "@/app/edit/lib/partialOptimizationApproval"; import { saveEditPageDraft } from "@/lib/session/editPageDraft"; import type { AddressCard, @@ -42,6 +48,17 @@ type OptimizationJobStatusResponse = { error?: string; }; +type PreflightResult = + | { ok: false } + | { + ok: true; + availableVehicles: VehicleRow[]; + lockedVehicles: LockedVehicleRow[]; + demandType: CapacityUnit; + totalDemand: number; + totalCapacity: number; + }; + function isLocked(v: VehicleRow): v is LockedVehicleRow { return v.locked && v.type !== "" && v.capacityUnit !== ""; } @@ -120,52 +137,58 @@ export function useOptimize( ); const [result, setResult] = useState(null); const [needsDepotAddress, setNeedsDepotAddress] = useState(false); + const allowPartialRef = useRef(false); + const optimizeInFlightRef = useRef(false); + + const setPartialApproval = useCallback((event: PartialApprovalEvent) => { + allowPartialRef.current = nextPartialApproval( + allowPartialRef.current, + event, + ); + }, []); - const optimize = useCallback( - async (depotAddress?: string, allowPartialOptimization = false) => { - setOptimizeError(null); - setCapacityWarning(null); - setGeocodeFailedAddressIds([]); - setGeocodeFailedVehicleIds([]); - setOutOfRegionAddressIds([]); - setOutOfRegionVehicleIds([]); - setOptimizationJobId(null); - setResult(null); - setNeedsDepotAddress(false); + const clearTransientOptimizeState = useCallback(() => { + setOptimizeError(null); + setCapacityWarning(null); + setGeocodeFailedAddressIds([]); + setGeocodeFailedVehicleIds([]); + setOutOfRegionAddressIds([]); + setOutOfRegionVehicleIds([]); + setOptimizationJobId(null); + setResult(null); + }, []); + const runPreflight = useCallback( + (setError: (message: string) => void): PreflightResult => { const unlockedVehicle = vehicles.find((v) => !v.locked); const unlockedAddress = addresses.find((a) => !a.locked); if (unlockedVehicle || unlockedAddress) { - setOptimizeError( + setError( "Please confirm all vehicles and addresses before optimizing.", ); - return; + return { ok: false }; } const availableVehicles = vehicles.filter((v) => v.available); if (availableVehicles.length === 0) { - setOptimizeError("At least one available vehicle is required."); - return; + setError("At least one available vehicle is required."); + return { ok: false }; } if (addresses.length === 0) { - setOptimizeError("At least one delivery address is required."); - return; + setError("At least one delivery address is required."); + return { ok: false }; } const lockedVehicles = availableVehicles.filter(isLocked); if (lockedVehicles.length !== availableVehicles.length) { - setOptimizeError( - "One or more vehicles are missing type or capacity unit.", - ); - return; + setError("One or more vehicles are missing type or capacity unit."); + return { ok: false }; } const units = [...new Set(availableVehicles.map((v) => v.capacityUnit))]; if (units.length > 1) { - setOptimizeError( - "All vehicles must use the same capacity unit to optimize.", - ); - return; + setError("All vehicles must use the same capacity unit to optimize."); + return { ok: false }; } const demandType = units[0] as CapacityUnit; @@ -177,19 +200,90 @@ export function useOptimize( (sum, v) => sum + v.capacity, 0, ); - if (totalDemand > totalCapacity && !allowPartialOptimization) { + + return { + ok: true, + availableVehicles, + lockedVehicles, + demandType, + totalDemand, + totalCapacity, + }; + }, + [vehicles, addresses], + ); + + const startOptimize = useCallback(() => { + setPartialApproval("fresh_start"); + clearTransientOptimizeState(); + setNeedsDepotAddress(false); + + const preflight = runPreflight(setOptimizeError); + if (!preflight.ok) return; + + if ( + shouldWarnForOverCapacity( + preflight.totalDemand, + preflight.totalCapacity, + allowPartialRef.current, + ) + ) { + setCapacityWarning( + capacityWarningMessage( + preflight.totalDemand, + preflight.totalCapacity, + ), + ); + return; + } + + setNeedsDepotAddress(true); + }, [clearTransientOptimizeState, runPreflight, setPartialApproval]); + + const optimizeAnyway = useCallback(() => { + setPartialApproval("approve"); + setCapacityWarning(null); + setNeedsDepotAddress(true); + }, [setPartialApproval]); + + const optimize = useCallback( + async (depotAddress: string) => { + if (optimizeInFlightRef.current || isOptimizing) return; + + clearTransientOptimizeState(); + setNeedsDepotAddress(false); + + const preflight = runPreflight(setOptimizeError); + if (!preflight.ok) { + setPartialApproval("run_finished"); + return; + } + + if ( + shouldWarnForOverCapacity( + preflight.totalDemand, + preflight.totalCapacity, + allowPartialRef.current, + ) + ) { setCapacityWarning( - `Total delivery quantity (${totalDemand}) exceeds total vehicle capacity (${totalCapacity}). Optimize anyway to create a partial route; deliveries that cannot fit within the available capacity will remain unassigned.`, + capacityWarningMessage( + preflight.totalDemand, + preflight.totalCapacity, + ), ); return; } - const trimmedDepotAddress = depotAddress?.trim(); + const trimmedDepotAddress = depotAddress.trim(); if (!trimmedDepotAddress) { setNeedsDepotAddress(true); return; } + const { availableVehicles, lockedVehicles, demandType } = preflight; + + optimizeInFlightRef.current = true; setIsOptimizing(true); try { // Geocode the shared depot address once and reuse it for all vehicles. @@ -408,15 +502,21 @@ export function useOptimize( "Network error. Please check your connection and try again.", ); } finally { + setPartialApproval("run_finished"); + optimizeInFlightRef.current = false; setIsOptimizing(false); } }, [ - vehicles, addresses, + cacheAddressLocation, + clearTransientOptimizeState, + isOptimizing, router, + runPreflight, + setPartialApproval, setVehiclesStartLocation, - cacheAddressLocation, + vehicles, ], ); @@ -425,14 +525,18 @@ export function useOptimize( }, []); const clearCapacityWarning = useCallback(() => { + setPartialApproval("dismiss"); setCapacityWarning(null); - }, []); + }, [setPartialApproval]); const dismissDepotAddressPrompt = useCallback(() => { + setPartialApproval("dismiss"); setNeedsDepotAddress(false); - }, []); + }, [setPartialApproval]); return { + startOptimize, + optimizeAnyway, optimize, isOptimizing, optimizeError, diff --git a/app/ui/src/app/edit/lib/partialOptimizationApproval.ts b/app/ui/src/app/edit/lib/partialOptimizationApproval.ts new file mode 100644 index 00000000..401c9813 --- /dev/null +++ b/app/ui/src/app/edit/lib/partialOptimizationApproval.ts @@ -0,0 +1,34 @@ +export type PartialApprovalEvent = + | "fresh_start" + | "approve" + | "dismiss" + | "run_finished"; + +export function shouldWarnForOverCapacity( + demand: number, + capacity: number, + allowPartial: boolean, +): boolean { + return demand > capacity && !allowPartial; +} + +export function nextPartialApproval( + _current: boolean, + event: PartialApprovalEvent, +): boolean { + switch (event) { + case "approve": + return true; + case "fresh_start": + case "dismiss": + case "run_finished": + return false; + } +} + +export function capacityWarningMessage( + totalDemand: number, + totalCapacity: number, +): string { + return `Total delivery quantity (${totalDemand}) exceeds total vehicle capacity (${totalCapacity}). Optimize anyway to create a partial route; deliveries that cannot fit within the available capacity will remain unassigned.`; +} diff --git a/app/ui/src/app/edit/page.tsx b/app/ui/src/app/edit/page.tsx index d4c27042..d4f882ad 100644 --- a/app/ui/src/app/edit/page.tsx +++ b/app/ui/src/app/edit/page.tsx @@ -71,13 +71,13 @@ export default function Page() { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isUploadOverlayOpen, setIsUploadOverlayOpen] = useState(false); const [pendingCSVFile, setPendingCSVFile] = useState(null); - const [allowPartialOptimization, setAllowPartialOptimization] = - useState(false); const { importVehicles } = vehicleState; const { importAddresses } = addressState; const { parseError, closeImportModal } = useCSVImport(); const { + startOptimize, + optimizeAnyway, optimize, isOptimizing, optimizeError, @@ -228,25 +228,15 @@ export default function Page() { const clearSessionError = useCallback(() => setSessionError(null), []); - const dismissCapacityWarning = useCallback(() => { - setAllowPartialOptimization(false); - clearCapacityWarning(); - }, [clearCapacityWarning]); - - const handleOptimizeAnyway = useCallback(() => { - setAllowPartialOptimization(true); - void optimize(undefined, true); - }, [optimize]); - const handleStartLocationSave = useCallback( (addr: LocationAddress) => { const parts = [addr.line1]; if (addr.line2.trim()) parts.push(addr.line2); parts.push(addr.city, `${addr.state} ${addr.zipCode}`, addr.country); const formattedAddress = parts.join(", "); - void optimize(formattedAddress, allowPartialOptimization); + void optimize(formattedAddress); }, - [allowPartialOptimization, optimize], + [optimize], ); function handlePageDragEnter(e: React.DragEvent) { @@ -308,11 +298,12 @@ export default function Page() { variant="warning" title="Vehicle capacity is limited" message={capacityWarning} - onClose={dismissCapacityWarning} + onClose={clearCapacityWarning} action={{ label: "Optimize Anyway", - onClick: handleOptimizeAnyway, + onClick: optimizeAnyway, }} + actionDisabled={isOptimizing} /> setUploadError(null)} /> @@ -321,11 +312,9 @@ export default function Page() { {needsDepotAddress && ( { - setAllowPartialOptimization(false); - dismissDepotAddressPrompt(); - }} + onClose={dismissDepotAddressPrompt} onSave={handleStartLocationSave} + primaryDisabled={isOptimizing} /> )} void optimize()} + onOptimize={startOptimize} isOptimizing={isOptimizing} /> setIsMobileMenuOpen(true)} /> @@ -355,7 +344,7 @@ export default function Page() { >
void optimize()} + onOptimize={startOptimize} isOptimizing={isOptimizing} /> { + it("warns when demand exceeds capacity and partial is not approved", () => { + expect(shouldWarnForOverCapacity(10, 5, false)).toBe(true); + }); + + it("does not warn when partial optimization is approved", () => { + expect(shouldWarnForOverCapacity(10, 5, true)).toBe(false); + }); + + it("does not warn when demand fits within capacity", () => { + expect(shouldWarnForOverCapacity(5, 10, false)).toBe(false); + }); +}); + +describe("nextPartialApproval", () => { + it("sets approval on approve", () => { + expect(nextPartialApproval(false, "approve")).toBe(true); + }); + + it("clears approval on fresh_start after a prior approve", () => { + const approved = nextPartialApproval(false, "approve"); + expect(nextPartialApproval(approved, "fresh_start")).toBe(false); + }); + + it("clears approval on dismiss after a prior approve", () => { + const approved = nextPartialApproval(false, "approve"); + expect(nextPartialApproval(approved, "dismiss")).toBe(false); + }); + + it("clears approval on run_finished after a prior approve", () => { + const approved = nextPartialApproval(false, "approve"); + expect(nextPartialApproval(approved, "run_finished")).toBe(false); + }); +}); + +describe("capacityWarningMessage", () => { + it("includes demand and capacity totals", () => { + expect(capacityWarningMessage(12, 8)).toContain("12"); + expect(capacityWarningMessage(12, 8)).toContain("8"); + }); +}); From 214e816c7a1262968a8859297715ad919bdebb5b Mon Sep 17 00:00:00 2001 From: Guritfak Gill Date: Sat, 25 Jul 2026 14:43:36 -0700 Subject: [PATCH 6/6] style(edit): format useOptimize capacity warning call Co-authored-by: Cursor --- app/ui/src/app/edit/hooks/useOptimize.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/ui/src/app/edit/hooks/useOptimize.ts b/app/ui/src/app/edit/hooks/useOptimize.ts index c6350e14..7a6ff4d8 100644 --- a/app/ui/src/app/edit/hooks/useOptimize.ts +++ b/app/ui/src/app/edit/hooks/useOptimize.ts @@ -229,10 +229,7 @@ export function useOptimize( ) ) { setCapacityWarning( - capacityWarningMessage( - preflight.totalDemand, - preflight.totalCapacity, - ), + capacityWarningMessage(preflight.totalDemand, preflight.totalCapacity), ); return; }