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/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/AlertPopup.tsx b/app/ui/src/app/edit/components/shared/AlertPopup.tsx new file mode 100644 index 00000000..8ef38f9f --- /dev/null +++ b/app/ui/src/app/edit/components/shared/AlertPopup.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { type KeyboardEvent, useId } from "react"; + +import { + ALERT_POPUP_FOOTER, + ALERT_POPUP_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"; + +export type AlertPopupVariant = "error" | "warning"; + +type AlertPopupProps = { + message: string | null; + onClose: () => void; + variant?: AlertPopupVariant; + title?: string; + action?: { + label: string; + onClick: () => void; + }; + actionDisabled?: boolean; +}; + +export default function AlertPopup({ + message, + onClose, + variant = "error", + title = variant === "warning" ? "Warning" : "Something went wrong", + action, + actionDisabled = false, +}: AlertPopupProps) { + const panelRef = useFocusTrap(!!message); + const titleId = useId(); + const messageId = useId(); + + function handleKeyDown(event: KeyboardEvent): void { + if (event.key === "Escape") { + onClose(); + } + } + + if (!message) return null; + + return ( +
+
+
+

+ {title} +

+ +
+

+ {message} +

+
+ {action && ( + + )} + +
+
+
+ ); +} diff --git a/app/ui/src/app/edit/components/shared/ErrorOverlay.tsx b/app/ui/src/app/edit/components/shared/ErrorOverlay.tsx deleted file mode 100644 index aedeefbf..00000000 --- a/app/ui/src/app/edit/components/shared/ErrorOverlay.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { - ERROR_OVERLAY_FOOTER, - ERROR_OVERLAY_MESSAGE, - OVERLAY_BACKDROP, - OVERLAY_CLOSE_BTN, - OVERLAY_HEADER, - OVERLAY_PANEL, - OVERLAY_PRIMARY_BTN, - OVERLAY_TITLE, -} from "@/app/edit/formStyles.v2"; -import styles from "@/app/edit/edit.module.css"; -import { useFocusTrap } from "@/app/edit/hooks/useFocusTrap"; - -type ErrorOverlayProps = { - message: string | null; - onClose: () => void; -}; - -export default function ErrorOverlay({ message, onClose }: ErrorOverlayProps) { - const panelRef = useFocusTrap(!!message); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; - - if (!message) return null; - - return ( -
-
-
-

- Something went wrong -

- -
-

{message}

-
- -
-
-
- ); -} diff --git a/app/ui/src/app/edit/edit.module.css b/app/ui/src/app/edit/edit.module.css index e01d0fbd..213d1d11 100644 --- a/app/ui/src/app/edit/edit.module.css +++ b/app/ui/src/app/edit/edit.module.css @@ -7,6 +7,7 @@ *
...
*/ +/* Page wrapper class; --edit-* button tokens live on :root in globals.css. */ .root { } diff --git a/app/ui/src/app/edit/formStyles.v2.ts b/app/ui/src/app/edit/formStyles.v2.ts index c0892095..409fea89 100644 --- a/app/ui/src/app/edit/formStyles.v2.ts +++ b/app/ui/src/app/edit/formStyles.v2.ts @@ -554,7 +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 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"; @@ -780,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"; +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/hooks/useOptimize.ts b/app/ui/src/app/edit/hooks/useOptimize.ts index 9310fae2..7a6ff4d8 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 !== ""; } @@ -102,6 +119,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[] >([]); @@ -119,51 +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) => { - setOptimizeError(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; @@ -175,19 +200,87 @@ 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.`, + + 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( + 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. @@ -406,15 +499,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, ], ); @@ -422,15 +521,25 @@ export function useOptimize( setOptimizeError(null); }, []); + const clearCapacityWarning = useCallback(() => { + setPartialApproval("dismiss"); + setCapacityWarning(null); + }, [setPartialApproval]); + const dismissDepotAddressPrompt = useCallback(() => { + setPartialApproval("dismiss"); setNeedsDepotAddress(false); - }, []); + }, [setPartialApproval]); return { + startOptimize, + optimizeAnyway, optimize, isOptimizing, optimizeError, clearOptimizeError, + capacityWarning, + clearCapacityWarning, needsDepotAddress, dismissDepotAddressPrompt, geocodeFailedAddressIds, 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 4665629d..d4f882ad 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"; @@ -76,10 +76,14 @@ export default function Page() { const { parseError, closeImportModal } = useCSVImport(); const { + startOptimize, + optimizeAnyway, optimize, isOptimizing, optimizeError, clearOptimizeError, + capacityWarning, + clearCapacityWarning, needsDepotAddress, dismissDepotAddressPrompt, geocodeFailedAddressIds, @@ -289,19 +293,28 @@ export default function Page() { /> )} - - - setUploadError(null)} + + - + + setUploadError(null)} /> + {needsDepotAddress && ( )} void optimize()} + onOptimize={startOptimize} isOptimizing={isOptimizing} /> setIsMobileMenuOpen(true)} /> @@ -331,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"); + }); +});