diff --git a/apps/shopify-app/app/features/delivery/child-route-detail-presentation.js b/apps/shopify-app/app/features/delivery/child-route-detail-presentation.js index 98a0fd4..655f1bc 100644 --- a/apps/shopify-app/app/features/delivery/child-route-detail-presentation.js +++ b/apps/shopify-app/app/features/delivery/child-route-detail-presentation.js @@ -14,6 +14,7 @@ export const CHILD_ROUTE_ORDER_COLUMNS = [ { key: "method", label: "Method" }, { key: "payment", label: "Payment" }, { key: "attributes", label: "Attributes" }, + { key: "actions", label: "Actions" }, ]; function textOrUndefined(value) { @@ -64,9 +65,14 @@ export function isMaterializedChildRouteDetail({ routePlan, routeGroup } = {}) { } export function formatChildOrderStatus(value) { - const status = String(value ?? "").trim().toLowerCase(); - if (status === "completed") return "Completed"; - if (status === "in_progress" || status === "in-progress") return "In progress"; + const status = String(value ?? "").trim().replace(/-/g, "_").toUpperCase(); + if (status === "READY" || status === "PENDING" || status === "ASSIGNED") return "Ready"; + if (status === "EN_ROUTE" || status === "ARRIVED") return "In progress"; + if (status === "DELIVERED" || status === "COMPLETED") return "Completed"; + if (status === "FAILED") return "Failed"; + if (status === "SKIPPED") return "Skipped"; + if (status === "CANCELLED") return "Cancelled"; + if (status === "IN_PROGRESS") return "In progress"; return "Preparing"; } @@ -306,6 +312,63 @@ function getCustomerName(stop) { ) ?? EMPTY_LABEL; } +function getStopPhone(stop) { + return firstText( + stop?.phone, + stop?.recipientPhone, + stop?.customerPhone, + stop?.shippingAddress?.phone, + stop?.address?.phone, + stop?.order?.phone, + stop?.order?.customer?.phone, + stop?.shopifyOrderSnapshot?.phone, + stop?.shopifyOrderSnapshot?.shippingAddress?.phone, + ); +} + +function getStopAddressField(stop, field) { + return firstText( + stop?.[field], + stop?.address?.[field], + stop?.shippingAddress?.[field], + stop?.order?.shippingAddress?.[field], + stop?.shopifyOrderSnapshot?.shippingAddress?.[field], + stop?.rawPayload?.shippingAddress?.[field], + ); +} + +function getStopLatitude(stop) { + return numberOrUndefined( + stop?.latitude ?? + stop?.coordinates?.latitude ?? + stop?.address?.latitude ?? + stop?.shippingAddress?.latitude ?? + (Array.isArray(stop?.coordinates) ? stop.coordinates[1] : undefined) + ); +} + +function getStopLongitude(stop) { + return numberOrUndefined( + stop?.longitude ?? + stop?.coordinates?.longitude ?? + stop?.address?.longitude ?? + stop?.shippingAddress?.longitude ?? + (Array.isArray(stop?.coordinates) ? stop.coordinates[0] : undefined) + ); +} + +function getStopInstructions(stop) { + return firstText( + stop?.instructions, + stop?.deliveryInstructions, + stop?.driverInstructions, + stop?.note, + stop?.order?.note, + stop?.shopifyOrderSnapshot?.note, + stop?.rawPayload?.note, + ); +} + function normalizeAttributes(attributes) { if (typeof attributes === "string") { const value = attributes.trim(); @@ -373,6 +436,10 @@ export function buildChildRouteOrderRows(stops, { ianaTimezone, timezoneAbbrevia return { id: firstText(stop?.id, stop?.deliveryStopId, stop?.shopifyOrderGid, stop?.orderId) ?? `child-order-${index + 1}`, + orderId: firstText(stop?.orderId, stop?.sourceOrderId), + deliveryStopId: firstText(stop?.deliveryStopId), + shopifyOrderGid: firstText(stop?.shopifyOrderGid), + shopifyOrderLegacyId: firstText(stop?.shopifyOrderLegacyId, stop?.legacyResourceId, stop?.shopifyOrderSnapshot?.legacyResourceId), stop: index + 1, order: firstText(stop?.order, stop?.orderName, stop?.sourceOrderId, stop?.shopifyOrderGid) ?? EMPTY_LABEL, status: formatChildOrderStatus(getOrderStatusSource(stop)), @@ -390,6 +457,22 @@ export function buildChildRouteOrderRows(stops, { ianaTimezone, timezoneAbbrevia attributes, attributesSummary: formatAttributesSummary(attributes), attributesDetail: attributes.length > 0 ? attributes.map((attribute) => attribute.label).join("\n") : EMPTY_LABEL, + editFields: { + recipientName: firstText(stop?.recipientName, stop?.recipient, stop?.customerName), + phone: getStopPhone(stop), + address1: getStopAddressField(stop, "address1"), + address2: getStopAddressField(stop, "address2"), + city: getStopAddressField(stop, "city"), + province: getStopAddressField(stop, "province"), + postalCode: getStopAddressField(stop, "postalCode"), + countryCode: getStopAddressField(stop, "countryCode"), + latitude: getStopLatitude(stop), + longitude: getStopLongitude(stop), + timeWindowStart: firstText(stop?.timeWindowStart), + timeWindowEnd: firstText(stop?.timeWindowEnd), + serviceMinutes: numberOrUndefined(stop?.serviceMinutes), + instructions: getStopInstructions(stop), + }, }; }); } diff --git a/apps/shopify-app/app/features/delivery/route-detail.server.js b/apps/shopify-app/app/features/delivery/route-detail.server.js index 5401ffd..d92aa7c 100644 --- a/apps/shopify-app/app/features/delivery/route-detail.server.js +++ b/apps/shopify-app/app/features/delivery/route-detail.server.js @@ -14,6 +14,8 @@ import { deleteDeliveryRoutePlan, fetchDeliveryRoutePlanDetail, refreshDeliveryRoutePlanOrderData, + transitionDeliveryRoutePlanStop, + updateDeliveryRoutePlanStop, } from "./route-plans.server"; import { firstArray, @@ -44,6 +46,7 @@ import { } from "./route-order-refresh"; const ROUTE_REFRESH_CONCURRENCY = 5; +const ROUTE_STOP_TRANSITION_STATUSES = new Set(["READY", "IN_PROGRESS", "COMPLETED"]); function roundPerfDuration(duration) { return Number(duration.toFixed(2)); @@ -293,6 +296,30 @@ function getRedirectSearch(request, deletedKeys = []) { return `${search ? `?${search}` : ""}${url.hash}`; } +function readRouteStopTransitionStatus(value) { + const status = textOrUndefined(value)?.toUpperCase(); + return status && ROUTE_STOP_TRANSITION_STATUSES.has(status) ? status : null; +} + +function readRouteStopOverridePayload(formData) { + return { + recipientName: textOrUndefined(formData.get("recipientName")) ?? null, + phone: textOrUndefined(formData.get("phone")) ?? null, + address1: textOrUndefined(formData.get("address1")) ?? null, + address2: textOrUndefined(formData.get("address2")) ?? null, + city: textOrUndefined(formData.get("city")) ?? null, + province: textOrUndefined(formData.get("province")) ?? null, + postalCode: textOrUndefined(formData.get("postalCode")) ?? null, + countryCode: textOrUndefined(formData.get("countryCode")) ?? null, + latitude: numberOrUndefined(formData.get("latitude")) ?? null, + longitude: numberOrUndefined(formData.get("longitude")) ?? null, + timeWindowStart: textOrUndefined(formData.get("timeWindowStart")) ?? null, + timeWindowEnd: textOrUndefined(formData.get("timeWindowEnd")) ?? null, + serviceMinutes: numberOrUndefined(formData.get("serviceMinutes")) ?? null, + instructions: textOrUndefined(formData.get("instructions")) ?? null, + }; +} + export const routeDetailLoader = async ({ params, request }) => { const routeId = cleanRoutePathParam(params.routeId); const routeGroupIdHint = getRouteGroupIdHint(request); @@ -510,6 +537,39 @@ export const routeDetailAction = async ({ params, request }) => { }); } + if (intent === "transitionRouteStop") { + const deliveryStopId = textOrUndefined(formData.get("deliveryStopId")); + const status = readRouteStopTransitionStatus(formData.get("status")); + if (!status) { + return { + routePlan: null, + stop: null, + errors: [{ message: "지원하지 않는 stop 상태입니다." }], + }; + } + return transitionDeliveryRoutePlanStop( + request, + routeId, + deliveryStopId, + { + status, + idempotencyKey: textOrUndefined(formData.get("idempotencyKey")), + }, + { sessionToken: shopifySessionToken }, + ); + } + + if (intent === "updateRouteStop") { + const deliveryStopId = textOrUndefined(formData.get("deliveryStopId")); + return updateDeliveryRoutePlanStop( + request, + routeId, + deliveryStopId, + readRouteStopOverridePayload(formData), + { sessionToken: shopifySessionToken }, + ); + } + if (intent === "previewRouteOptimization") { const draft = readRouteDraftPayload(formData.get("draft")); const result = await previewDeliveryRouteGroupOptimization( diff --git a/apps/shopify-app/app/features/delivery/route-plans.server.js b/apps/shopify-app/app/features/delivery/route-plans.server.js index 9ead3ef..308ba9b 100644 --- a/apps/shopify-app/app/features/delivery/route-plans.server.js +++ b/apps/shopify-app/app/features/delivery/route-plans.server.js @@ -12,6 +12,7 @@ export const DELIVERY_API_ENDPOINT_NOT_FOUND_ERROR_CODE = export const DELIVERY_API_DRIVER_ENDPOINT_NOT_FOUND_ERROR_CODE = "DELIVERY_API_DRIVER_ENDPOINT_NOT_FOUND"; export const DELIVERY_ROUTE_PLAN_ID_MISSING_ERROR_CODE = "DELIVERY_ROUTE_PLAN_ID_MISSING"; +export const DELIVERY_ROUTE_STOP_ID_MISSING_ERROR_CODE = "DELIVERY_ROUTE_STOP_ID_MISSING"; export { buildRouteScopeFromOrders } from "./route-scope.js"; const deliveryApiGetCache = new Map(); @@ -246,6 +247,107 @@ export async function updateDeliveryRoutePlanStops(request, routePlanId, payload }; } +export async function transitionDeliveryRoutePlanStop(request, routePlanId, deliveryStopId, payload, options = {}) { + const normalizedRoutePlanId = textOrNull(routePlanId); + const normalizedDeliveryStopId = textOrNull(deliveryStopId); + + if (!normalizedRoutePlanId) { + return { + routePlan: null, + stop: null, + errors: [ + { + code: DELIVERY_ROUTE_PLAN_ID_MISSING_ERROR_CODE, + message: "수정할 route plan ID가 없어 route stop 상태를 저장하지 못했습니다.", + }, + ], + }; + } + if (!normalizedDeliveryStopId) { + return { + routePlan: null, + stop: null, + errors: [ + { + code: DELIVERY_ROUTE_STOP_ID_MISSING_ERROR_CODE, + message: "수정할 delivery stop ID가 없어 route stop 상태를 저장하지 못했습니다.", + }, + ], + }; + } + + const safeRoutePlanId = encodeURIComponent(normalizedRoutePlanId); + const safeDeliveryStopId = encodeURIComponent(normalizedDeliveryStopId); + const result = await deliveryApiRequest( + request, + `/admin/route-plans/${safeRoutePlanId}/stops/${safeDeliveryStopId}/transition`, + { + body: JSON.stringify({ + status: textOrNull(payload?.status), + idempotencyKey: textOrNull(payload?.idempotencyKey), + }), + fetch: options.fetch, + method: "POST", + sessionToken: options.sessionToken, + }, + ); + + return { + routePlan: result.data?.routePlan ?? null, + stop: result.data?.stop ?? result.data?.deliveryStop ?? null, + errors: result.errors, + }; +} + +export async function updateDeliveryRoutePlanStop(request, routePlanId, deliveryStopId, payload, options = {}) { + const normalizedRoutePlanId = textOrNull(routePlanId); + const normalizedDeliveryStopId = textOrNull(deliveryStopId); + + if (!normalizedRoutePlanId) { + return { + routePlan: null, + stop: null, + errors: [ + { + code: DELIVERY_ROUTE_PLAN_ID_MISSING_ERROR_CODE, + message: "수정할 route plan ID가 없어 route stop을 저장하지 못했습니다.", + }, + ], + }; + } + if (!normalizedDeliveryStopId) { + return { + routePlan: null, + stop: null, + errors: [ + { + code: DELIVERY_ROUTE_STOP_ID_MISSING_ERROR_CODE, + message: "수정할 delivery stop ID가 없어 route stop을 저장하지 못했습니다.", + }, + ], + }; + } + + const safeRoutePlanId = encodeURIComponent(normalizedRoutePlanId); + const safeDeliveryStopId = encodeURIComponent(normalizedDeliveryStopId); + const result = await deliveryApiRequest( + request, + `/admin/route-plans/${safeRoutePlanId}/stops/${safeDeliveryStopId}/override`, + { + body: JSON.stringify(payload ?? {}), + fetch: options.fetch, + method: "PATCH", + sessionToken: options.sessionToken, + }, + ); + + return { + routePlan: result.data?.routePlan ?? null, + stop: result.data?.stop ?? result.data?.deliveryStop ?? null, + errors: result.errors, + }; +} + export async function updateDeliveryRoutePlanDepartureTime(request, routePlanId, payload, options = {}) { const normalizedRoutePlanId = textOrNull(routePlanId); diff --git a/apps/shopify-app/app/features/delivery/route-plans.server.test.js b/apps/shopify-app/app/features/delivery/route-plans.server.test.js index b65e741..72ef5ef 100644 --- a/apps/shopify-app/app/features/delivery/route-plans.server.test.js +++ b/apps/shopify-app/app/features/delivery/route-plans.server.test.js @@ -14,6 +14,8 @@ import { fetchDeliveryRoutePlans, getDeliveryApiBaseUrl, getShopifySessionBearer, + transitionDeliveryRoutePlanStop, + updateDeliveryRoutePlanStop, updateDeliveryRoutePlanStops, updateDeliveryRoutePlanDepartureTime, updateDeliveryRoutePlanScheduledStart, @@ -586,6 +588,106 @@ test("updates route plan stops through the delivery Admin API", async () => { assert.deepEqual(result.errors, []); }); +test("transitions a route plan stop through the delivery Admin API", async () => { + const previousBaseUrl = process.env.CLEVER_DELIVERY_API_URL; + process.env.CLEVER_DELIVERY_API_URL = "https://delivery.example"; + const calls = []; + const result = await transitionDeliveryRoutePlanStop( + new Request("https://app.example/app/routes/route-1"), + "route 1", + "stop 1", + { idempotencyKey: "route-stop-transition-1", status: "READY" }, + { + fetch: async (url, options) => { + calls.push({ url, options }); + return Response.json({ + data: { + routePlan: { id: "route 1" }, + stop: { deliveryStopId: "stop 1", status: "READY" }, + }, + error: null, + }); + }, + sessionToken: "client-session-token", + }, + ); + + process.env.CLEVER_DELIVERY_API_URL = previousBaseUrl; + + assert.equal(calls[0].url, "https://delivery.example/admin/route-plans/route%201/stops/stop%201/transition"); + assert.equal(calls[0].options.method, "POST"); + assert.equal(calls[0].options.headers.authorization, "Bearer client-session-token"); + assert.deepEqual(JSON.parse(calls[0].options.body), { + idempotencyKey: "route-stop-transition-1", + status: "READY", + }); + assert.equal(result.stop.status, "READY"); + assert.deepEqual(result.errors, []); +}); + +test("updates flat operational route plan stop override fields through the delivery Admin API", async () => { + const previousBaseUrl = process.env.CLEVER_DELIVERY_API_URL; + process.env.CLEVER_DELIVERY_API_URL = "https://delivery.example"; + const calls = []; + const result = await updateDeliveryRoutePlanStop( + new Request("https://app.example/app/routes/route-1"), + "route 1", + "stop 1", + { + address1: "10 Test St", + address2: "Unit 2", + city: "Toronto", + countryCode: "CA", + instructions: "Use side door", + latitude: 43.7, + longitude: -79.4, + phone: "+14165550123", + postalCode: "M1M 1M1", + province: "ON", + recipientName: "Kim Minji", + serviceMinutes: 7, + timeWindowEnd: "21:00", + timeWindowStart: "17:00", + }, + { + fetch: async (url, options) => { + calls.push({ url, options }); + return Response.json({ + data: { + routePlan: { id: "route 1" }, + stop: { deliveryStopId: "stop 1", recipientName: "Kim Minji" }, + }, + error: null, + }); + }, + sessionToken: "client-session-token", + }, + ); + + process.env.CLEVER_DELIVERY_API_URL = previousBaseUrl; + + assert.equal(calls[0].url, "https://delivery.example/admin/route-plans/route%201/stops/stop%201/override"); + assert.equal(calls[0].options.method, "PATCH"); + assert.deepEqual(JSON.parse(calls[0].options.body), { + address1: "10 Test St", + address2: "Unit 2", + city: "Toronto", + countryCode: "CA", + instructions: "Use side door", + latitude: 43.7, + longitude: -79.4, + phone: "+14165550123", + postalCode: "M1M 1M1", + province: "ON", + recipientName: "Kim Minji", + serviceMinutes: 7, + timeWindowEnd: "21:00", + timeWindowStart: "17:00", + }); + assert.equal(result.stop.recipientName, "Kim Minji"); + assert.deepEqual(result.errors, []); +}); + test("updates a child route departure time through the delivery Admin API", async () => { const previousBaseUrl = process.env.CLEVER_DELIVERY_API_URL; process.env.CLEVER_DELIVERY_API_URL = "https://delivery.example"; diff --git a/apps/shopify-app/app/routes/app.routes.$routeId.jsx b/apps/shopify-app/app/routes/app.routes.$routeId.jsx index 57ab256..c196c8e 100644 --- a/apps/shopify-app/app/routes/app.routes.$routeId.jsx +++ b/apps/shopify-app/app/routes/app.routes.$routeId.jsx @@ -93,6 +93,25 @@ const CHILD_ORDER_DISCLOSURE_EDGE_INSET = 12; const CHILD_ORDER_DISCLOSURE_GAP = 2; const CHILD_ORDER_DISCLOSURE_HEIGHT = 260; const CHILD_ORDER_DISCLOSURE_WIDTH = 300; +const CHILD_STOP_ACTIONS_EDGE_INSET = 12; +const CHILD_STOP_ACTIONS_GAP = 4; +const CHILD_STOP_ACTIONS_WIDTH = 248; +const CHILD_STOP_EDIT_FIELDS = [ + ["recipientName", "Recipient"], + ["phone", "Phone"], + ["address1", "Address 1"], + ["address2", "Address 2"], + ["city", "City"], + ["province", "Province"], + ["postalCode", "Postal code"], + ["countryCode", "Country code"], + ["latitude", "Latitude"], + ["longitude", "Longitude"], + ["timeWindowStart", "Time window start"], + ["timeWindowEnd", "Time window end"], + ["serviceMinutes", "Stop time minutes"], + ["instructions", "Instructions"], +]; function roundPerfDuration(duration) { return Number(duration.toFixed(2)); @@ -696,7 +715,7 @@ const routePlanRowsColumnWidths = [ const childRouteOrderTableStyle = { borderCollapse: "separate", borderSpacing: 0, - minWidth: "1424px", + minWidth: "1500px", tableLayout: "fixed", width: "100%", }; @@ -715,6 +734,7 @@ const childRouteOrderColumnWidths = [ "132px", "104px", "94px", + "76px", ]; const childRouteOrderRowStyle = { @@ -859,6 +879,83 @@ const childRouteDisclosureEmptyStyle = { color: "#6d7175", }; +const childRouteActionsHeaderCellStyle = { + ...childRouteOrderHeaderCellStyle, + background: "#f7f7f7", + boxShadow: "-8px 0 12px rgba(255, 255, 255, 0.92)", + position: "sticky", + right: 0, + zIndex: 3, +}; + +const childRouteActionsCellStyle = { + ...childRouteOrderCellStyle, + background: "#ffffff", + boxShadow: "-8px 0 12px rgba(255, 255, 255, 0.92)", + overflow: "visible", + position: "sticky", + right: 0, + zIndex: 2, +}; + +const childStopActionsButtonStyle = { + ...routeActionButtonStyle, + minHeight: "28px", + padding: "2px 8px", +}; + +const childStopActionsMenuStyle = { + background: "#ffffff", + border: "1px solid #d6d6d6", + borderRadius: "10px", + boxShadow: "0 12px 32px rgba(0, 0, 0, 0.16)", + color: "#303030", + display: "grid", + gap: "3px", + left: 0, + padding: "7px", + position: "fixed", + top: 0, + width: `${CHILD_STOP_ACTIONS_WIDTH}px`, + zIndex: 100030, +}; + +const childStopActionsHeadingStyle = { + color: "#616161", + fontSize: "12px", + fontWeight: 700, + padding: "5px 8px 3px", +}; + +const childStopActionsMenuItemStyle = { + ...siblingRouteMenuItemStyle, + minHeight: "32px", + padding: "6px 8px", +}; + +const childStopActionsExternalLinkStyle = { + ...childStopActionsMenuItemStyle, + boxSizing: "border-box", + textDecoration: "none", +}; + +const childStopActionsDividerStyle = { + borderTop: "1px solid #eeeeee", + margin: "4px 0", +}; + +const childStopEditReadonlyStyle = { + background: "#f7f7f7", + border: "1px solid #e3e3e3", + borderRadius: "8px", + color: "#616161", + display: "grid", + fontSize: "12px", + gap: "4px", + lineHeight: 1.35, + padding: "8px", +}; + const childRouteTimelineRowsStyle = { display: "grid", gap: "6px", @@ -907,6 +1004,29 @@ function getChildOrderDisclosurePopoverPosition(rect, popoverSize = {}) { return { left, top, width }; } +function getChildStopActionsMenuPosition(rect, popoverSize = {}) { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const width = Math.min( + popoverSize.width ?? CHILD_STOP_ACTIONS_WIDTH, + viewportWidth - CHILD_STOP_ACTIONS_EDGE_INSET * 2, + ); + const height = Math.min( + popoverSize.height ?? 360, + viewportHeight - CHILD_STOP_ACTIONS_EDGE_INSET * 2, + ); + const left = Math.min( + Math.max(CHILD_STOP_ACTIONS_EDGE_INSET, rect.right - width), + viewportWidth - width - CHILD_STOP_ACTIONS_EDGE_INSET, + ); + const belowTop = rect.bottom + CHILD_STOP_ACTIONS_GAP; + const top = belowTop + height <= viewportHeight - CHILD_STOP_ACTIONS_EDGE_INSET + ? belowTop + : Math.max(CHILD_STOP_ACTIONS_EDGE_INSET, rect.top - height - CHILD_STOP_ACTIONS_GAP); + + return { left, top, width }; +} + const childRouteTimelineStopUnitStyle = { alignContent: "center", alignItems: "center", @@ -1708,6 +1828,12 @@ function getLiveTrackingStopStatus(row, progress) { return row.status; } +function isRouteExecutionLockedForStopMembership(status) { + return ["IN_PROGRESS", "EN_ROUTE", "ARRIVED", "COMPLETED", "DELIVERED", "FAILED", "SKIPPED", "CANCELLED"].includes( + String(status ?? "").trim().replace(/-/g, "_").toUpperCase(), + ); +} + function countRouteStopsByStatus(routeStops, statuses) { const statusSet = new Set(statuses); @@ -1835,6 +1961,43 @@ function normalizeRouteStopCoordinates(stop) { ); } +function getRouteStopAddressValue(stop, field) { + return textOrUndefined( + stop?.[field] ?? + stop?.address?.[field] ?? + stop?.shippingAddress?.[field] ?? + stop?.order?.shippingAddress?.[field] ?? + stop?.shopifyOrderSnapshot?.shippingAddress?.[field] ?? + stop?.rawPayload?.shippingAddress?.[field], + ); +} + +function getRouteStopPhone(stop) { + return textOrUndefined( + stop?.phone ?? + stop?.recipientPhone ?? + stop?.customerPhone ?? + stop?.address?.phone ?? + stop?.shippingAddress?.phone ?? + stop?.order?.phone ?? + stop?.order?.customer?.phone ?? + stop?.shopifyOrderSnapshot?.phone ?? + stop?.shopifyOrderSnapshot?.shippingAddress?.phone, + ); +} + +function getRouteStopInstructions(stop) { + return textOrUndefined( + stop?.instructions ?? + stop?.deliveryInstructions ?? + stop?.driverInstructions ?? + stop?.note ?? + stop?.order?.note ?? + stop?.shopifyOrderSnapshot?.note ?? + stop?.rawPayload?.note, + ); +} + function formatRouteStopItemOptions(options) { if (!Array.isArray(options)) return textOrUndefined(options); return options @@ -1921,6 +2084,7 @@ function buildRouteStops(stops) { orderId: textOrUndefined(stop.orderId) ?? null, routePlanId: textOrUndefined(stop.routePlanId ?? stop.routePlan?.id ?? stop.routeGroupingChild?.routePlanId) ?? null, shopifyOrderGid: textOrUndefined(stop.shopifyOrderGid), + shopifyOrderLegacyId: textOrUndefined(stop.shopifyOrderLegacyId ?? stop.legacyResourceId ?? stop.shopifyOrderSnapshot?.legacyResourceId), originalIndex: index, sequence: numberOrUndefined(stop.sequence), sourceSequence: numberOrUndefined(stop.sourceSequence), @@ -1929,6 +2093,16 @@ function buildRouteStops(stops) { order: stop.orderName ?? stop.sourceOrderId ?? stop.shopifyOrderGid, recipient: stop.recipientName ?? stop.recipient ?? stop.customerName ?? "Unknown recipient", address: textOrUndefined(stop.addressLabel) ?? formatStopAddress(stop.address), + recipientName: textOrUndefined(stop.recipientName ?? stop.recipient ?? stop.customerName), + phone: getRouteStopPhone(stop), + address1: getRouteStopAddressValue(stop, "address1"), + address2: getRouteStopAddressValue(stop, "address2"), + city: getRouteStopAddressValue(stop, "city"), + province: getRouteStopAddressValue(stop, "province"), + postalCode: getRouteStopAddressValue(stop, "postalCode"), + countryCode: getRouteStopAddressValue(stop, "countryCode"), + latitude: coordinates?.[1] ?? null, + longitude: coordinates?.[0] ?? null, status: stop.fulfillmentStatus ?? stop.status ?? stop.assignmentStatus ?? "PENDING", deliveryStatus: textOrUndefined(stop.deliveryStatus), deliveryStopStatus: textOrUndefined(stop.deliveryStopStatus), @@ -1943,6 +2117,9 @@ function buildRouteStops(stops) { distanceFromPreviousMeters: numberOrUndefined(stop.distanceFromPreviousMeters), serviceMinutes: numberOrUndefined(stop.serviceMinutes), serviceType: textOrUndefined(stop.serviceType ?? stop.method), + timeWindowEnd: textOrUndefined(stop.timeWindowEnd), + timeWindowStart: textOrUndefined(stop.timeWindowStart), + instructions: getRouteStopInstructions(stop), itemCount, items, canonicalLineItems: stop.canonicalLineItems, @@ -2454,6 +2631,19 @@ function renderChildRouteInfoIcon() { ); } +function getShopifyOrderResourceId(row) { + const legacyResourceId = textOrUndefined(row?.shopifyOrderLegacyId); + if (legacyResourceId) return legacyResourceId; + + const gidResourceId = textOrUndefined(row?.shopifyOrderGid)?.match(/\/Order\/([^/?#]+)/)?.[1]; + return gidResourceId ? decodeURIComponent(gidResourceId) : null; +} + +function getShopifyOrderAdminHref(row) { + const resourceId = getShopifyOrderResourceId(row); + return resourceId ? `shopify://admin/orders/${encodeURIComponent(resourceId)}` : null; +} + export default function RouteDetailPage() { const navigate = useNavigate(); const revalidator = useRevalidator(); @@ -2562,6 +2752,9 @@ export default function RouteDetailPage() { const childOrderDisclosureCloseButtonRef = useRef(null); const childOrderDisclosurePopoverRef = useRef(null); const childOrderDisclosureTriggerRef = useRef(null); + const childStopActionsButtonRefs = useRef(new Map()); + const childStopActionsMenuRef = useRef(null); + const childStopActionsTriggerRef = useRef(null); const routeTimelineDragRef = useRef(null); const routeTimelineDragSnapshotRef = useRef(null); const routeTimelineDropCommittedRef = useRef(false); @@ -2604,6 +2797,10 @@ export default function RouteDetailPage() { const [routeTimelineDrag, setRouteTimelineDrag] = useState(null); const [activeRouteTimelineStopPopover, setActiveRouteTimelineStopPopover] = useState(null); const [activeChildOrderDisclosure, setActiveChildOrderDisclosure] = useState(null); + const [activeChildStopActions, setActiveChildStopActions] = useState(null); + const [activeChildStopEditRow, setActiveChildStopEditRow] = useState(null); + const [childStopEditDraft, setChildStopEditDraft] = useState({}); + const [focusedTrackingStopId, setFocusedTrackingStopId] = useState(null); const [activeRouteSelector, setActiveRouteSelector] = useState(null); const [routeSelectorQuery, setRouteSelectorQuery] = useState(""); const [routeStartTimeDraft, setRouteStartTimeDraft] = useState(() => buildRouteStartDraft(routeStartDateTimeValue, routeStartTimeZone)); @@ -2683,6 +2880,7 @@ export default function RouteDetailPage() { () => getRouteTrackingPresentation(routeExecutionStatus, displayedRouteTrackingSnapshot, routeTrackingClock), [displayedRouteTrackingSnapshot, routeExecutionStatus, routeTrackingClock], ); + const canDraftEditChildStopMembership = !isRouteExecutionLockedForStopMembership(routeExecutionStatus); const liveTrackingRoutePlanId = routeExecutionStatus === "IN_PROGRESS" ? trackingRoutePlanId : null; const trackingStreamRoutePlanId = ["READY", "IN_PROGRESS"].includes(routeExecutionStatus) ? trackingRoutePlanId @@ -2702,6 +2900,16 @@ export default function RouteDetailPage() { const activeChildOrderDisclosureRow = activeChildOrderDisclosure ? childRouteOrderRows.find((row) => row.id === activeChildOrderDisclosure.rowId) ?? null : null; + const activeChildStopActionsRow = activeChildStopActions + ? childRouteOrderRows.find((row) => row.id === activeChildStopActions.rowId) ?? null + : null; + const activeChildStopShopifyHref = getShopifyOrderAdminHref(activeChildStopActionsRow); + const activeChildStopSourceRouteId = activeChildStopActionsRow + ? timelineRouteRows.find((routeRow) => routeRow.stops.some((stop) => stop.id === activeChildStopActionsRow.id))?.id + : null; + const childStopSendTargetRows = activeChildStopActionsRow + ? timelineRouteRows.filter((routeRow) => !routeRow.isPreviewOnly && routeRow.id !== activeChildStopSourceRouteId) + : []; const activeRouteTimelineStop = activeRouteTimelineStopPopover ? timelineRouteRows.flatMap((routeRow) => routeRow.stops).find((stop) => stop.id === activeRouteTimelineStopPopover.stopId) : null; @@ -3433,6 +3641,142 @@ export default function RouteDetailPage() { popoverNode.style.transform = `translate3d(${Math.round(nextPosition.left)}px, ${Math.round(nextPosition.top)}px, 0)`; }, []); + const setChildStopActionsButtonRef = useCallback((rowId, node) => { + if (node) { + childStopActionsButtonRefs.current.set(rowId, node); + return; + } + + childStopActionsButtonRefs.current.delete(rowId); + }, []); + + const getChildStopActionsState = (event, rowId, sendTargetsOpen = false) => { + childStopActionsTriggerRef.current = event.currentTarget; + return { + ...getChildStopActionsMenuPosition(event.currentTarget.getBoundingClientRect()), + actionKey: globalThis.crypto?.randomUUID?.() ?? String(Date.now()), + rowId, + sendTargetsOpen, + }; + }; + + const handleToggleChildStopActions = (event, rowId) => { + event.stopPropagation(); + const next = getChildStopActionsState(event, rowId); + setActiveChildStopActions((current) => ( + current?.rowId === rowId ? null : next + )); + }; + + const positionChildStopActionsMenu = useCallback(() => { + const triggerNode = childStopActionsTriggerRef.current; + const menuNode = childStopActionsMenuRef.current; + if (!triggerNode || !menuNode) return; + + const nextPosition = getChildStopActionsMenuPosition(triggerNode.getBoundingClientRect(), { + height: menuNode.offsetHeight, + width: menuNode.offsetWidth, + }); + menuNode.style.transform = `translate3d(${Math.round(nextPosition.left)}px, ${Math.round(nextPosition.top)}px, 0)`; + }, []); + + const closeChildStopActions = () => { + const trigger = childStopActionsTriggerRef.current; + setActiveChildStopActions(null); + window.requestAnimationFrame(() => trigger?.focus()); + }; + + const handleMarkChildStopStatus = (row, status) => { + if (!row?.deliveryStopId || routeGroupActionBusy) return; + submitRouteAction("transitionRouteStop", { + deliveryStopId: row.deliveryStopId, + idempotencyKey: `${effectiveRoutePlan?.id ?? "route"}:${row.deliveryStopId}:${status}:${activeChildStopActions?.actionKey ?? Date.now()}`, + status, + }); + setActiveChildStopActions(null); + }; + + const handleOpenChildStopEditor = (row) => { + if (!row?.deliveryStopId) return; + setChildStopEditDraft(row.editFields ?? {}); + setActiveChildStopEditRow(row); + setActiveChildStopActions(null); + }; + + const handleSaveChildStopEdit = () => { + if (!activeChildStopEditRow?.deliveryStopId || routeGroupActionBusy) return; + submitRouteAction("updateRouteStop", { + deliveryStopId: activeChildStopEditRow.deliveryStopId, + recipientName: childStopEditDraft.recipientName ?? "", + phone: childStopEditDraft.phone ?? "", + address1: childStopEditDraft.address1 ?? "", + address2: childStopEditDraft.address2 ?? "", + city: childStopEditDraft.city ?? "", + province: childStopEditDraft.province ?? "", + postalCode: childStopEditDraft.postalCode ?? "", + countryCode: childStopEditDraft.countryCode ?? "", + latitude: childStopEditDraft.latitude ?? "", + longitude: childStopEditDraft.longitude ?? "", + timeWindowStart: childStopEditDraft.timeWindowStart ?? "", + timeWindowEnd: childStopEditDraft.timeWindowEnd ?? "", + serviceMinutes: childStopEditDraft.serviceMinutes ?? "", + instructions: childStopEditDraft.instructions ?? "", + }); + setActiveChildStopEditRow(null); + }; + + const handleRemoveChildStopFromGroup = (row) => { + if (!canDraftEditChildStopMembership || !row?.id) return; + setRoutePreviewByKey({}); + if (row.orderId) setRemovedOrderIds((orderIds) => [...new Set([...orderIds, row.orderId])]); + animateRouteTimelineChange(() => { + setRouteTimelineOrderByRouteId((currentOrderByRouteId) => removeTimelineStop( + routeRows, + currentOrderByRouteId, + { stopId: row.id }, + )); + }); + setActiveChildStopActions(null); + }; + + const handleSendChildStopToRoute = (row, targetRouteRow) => { + if (!canDraftEditChildStopMembership || !row?.id || !targetRouteRow || targetRouteRow.isPreviewOnly) return; + setRoutePreviewByKey({}); + animateRouteTimelineChange(() => { + setRouteTimelineOrderByRouteId((currentOrderByRouteId) => moveTimelineStop( + routeRows, + currentOrderByRouteId, + { stopId: row.id }, + targetRouteRow.id, + )); + }); + setActiveChildStopActions(null); + }; + + const handleOpenChildStopSendTargets = () => { + setActiveChildStopActions((current) => current ? { ...current, sendTargetsOpen: !current.sendTargetsOpen } : current); + }; + + const handleOpenChildStopTracking = (row) => { + if (!row) return; + setFocusedTrackingStopId(row.id); + setChildDetailTab("tracking"); + setActiveChildStopActions(null); + const stop = routeMapStops.find((candidate) => ( + candidate.id === row.id || + candidate.deliveryStopId === row.deliveryStopId || + candidate.shopifyOrderGid === row.shopifyOrderGid + )); + if (stop && isMapReady && mapRef.current && mapLibraryRef.current) { + fitRouteStopAndSnappedPoint( + mapRef.current, + mapLibraryRef.current, + stop, + findRouteStopPoint(stop, savedRouteStopPoints), + ); + } + }; + const activeRouteTimelineStopPopoverId = activeRouteTimelineStopPopover?.stopId; useEffect(() => { @@ -3503,6 +3847,41 @@ export default function RouteDetailPage() { }; }, [activeChildOrderDisclosure, positionChildOrderDisclosurePopover]); + useEffect(() => { + if (!activeChildStopActions) return undefined; + + const syncChildStopActionsMenu = () => positionChildStopActionsMenu(); + positionChildStopActionsMenu(); + window.addEventListener("resize", syncChildStopActionsMenu); + window.addEventListener("scroll", syncChildStopActionsMenu, true); + return () => { + window.removeEventListener("resize", syncChildStopActionsMenu); + window.removeEventListener("scroll", syncChildStopActionsMenu, true); + }; + }, [activeChildStopActions, positionChildStopActionsMenu]); + + useEffect(() => { + if (!activeChildStopActions) return undefined; + + const handleDocumentPointerDown = (event) => { + if (event.target?.closest?.('[data-child-stop-actions-trigger="true"]')) return; + if (event.target?.closest?.('[data-child-stop-actions-menu="true"]')) return; + setActiveChildStopActions(null); + }; + const handleDocumentKeyDown = (event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + closeChildStopActions(); + }; + + document.addEventListener("pointerdown", handleDocumentPointerDown); + document.addEventListener("keydown", handleDocumentKeyDown); + return () => { + document.removeEventListener("pointerdown", handleDocumentPointerDown); + document.removeEventListener("keydown", handleDocumentKeyDown); + }; + }, [activeChildStopActions]); + useEffect(() => () => { if (childOrderDisclosureCloseTimerRef.current != null) { window.clearTimeout(childOrderDisclosureCloseTimerRef.current); @@ -3863,6 +4242,17 @@ export default function RouteDetailPage() { shopify.toast.show(`${updatedOrders} orders updated across ${refreshedRoutes} routes${skippedMessage}`); }, [revalidator, routeActionFetcher.data, routeActionFetcher.state, shopify]); + useEffect(() => { + if (routeActionFetcher.state !== "idle" || routeActionFetcher.data === undefined) return; + if (!["transitionRouteStop", "updateRouteStop"].includes(lastRouteActionIntentRef.current)) return; + const intent = lastRouteActionIntentRef.current; + lastRouteActionIntentRef.current = null; + if ((routeActionFetcher.data?.errors ?? []).length > 0) return; + + revalidator.revalidate(); + shopify.toast.show(intent === "transitionRouteStop" ? "Stop status updated" : "Stop fields updated"); + }, [revalidator, routeActionFetcher.data, routeActionFetcher.state, shopify]); + useEffect(() => { if (routeActionFetcher.state !== "idle" || routeActionFetcher.data === undefined) return; if (lastRouteActionIntentRef.current !== "queryNextRouteIdx") return; @@ -5027,7 +5417,12 @@ export default function RouteDetailPage() { {CHILD_ROUTE_ORDER_COLUMNS.map((column) => ( - {column.label} + + {column.label} + ))} @@ -5081,6 +5476,24 @@ export default function RouteDetailPage() { {renderChildRouteInfoIcon()} + + + ))} @@ -5175,7 +5588,13 @@ export default function RouteDetailPage() { {childRouteOrderRows.map((row) => ( - + , document.body, ) : null} + {activeChildStopActions && activeChildStopActionsRow && typeof document !== "undefined" ? createPortal( +
+
Mark as…
+ + + +
+ + + + {activeChildStopActions.sendTargetsOpen ? ( +
+ {childStopSendTargetRows.map((routeRow) => ( + + ))} +
+ ) : null} + {activeChildStopShopifyHref ? ( + setActiveChildStopActions(null)} + rel="noreferrer" + role="menuitem" + style={childStopActionsExternalLinkStyle} + target="_blank" + > + View in Shopify + + ) : ( + + )} + +
, + document.body, + ) : null} {isRouteDraftExitDialogOpen ? ( @@ -5509,6 +6050,54 @@ export default function RouteDetailPage() {
) : null} + {activeChildStopEditRow ? ( +
+ + +
+ + + ) : null} + {activeRouteSelector ? (
,", actionsMenuStart); + const actionsMenuSource = routeDetailSource.slice(actionsMenuStart, actionsMenuEnd); + assert.deepEqual(CHILD_ROUTE_ORDER_COLUMNS.map((column) => column.label), [ "Stop", "Order", @@ -166,14 +177,99 @@ test("child order table columns are exact and excluded columns stay out of the c "Method", "Payment", "Attributes", + "Actions", ]); assert.match(routeDetailSource, /aria-label="Child route order stops"/); assert.match(routeDetailSource, /CHILD_ROUTE_ORDER_COLUMNS\.map\(\(column\) =>/); assert.match(routeDetailSource, /childRouteOrderRows\.map\(\(row\) =>/); assert.match(routeDetailSource, /\{row\.payment\}<\/td>/); - assert.doesNotMatch(routeDetailSource, /aria-label="Open Shopify order"/); - assert.doesNotMatch(routeDetailSource, /aria-label="Remove order from route"/); + assert.match(routeDetailSource, /const childRouteActionsHeaderCellStyle = \{/); + assert.match(routeDetailSource, /const childRouteActionsCellStyle = \{/); + assert.match(routeDetailSource, /minWidth: "1500px"/); + assert.match(routeDetailSource, /const childRouteActionsHeaderCellStyle = \{[\s\S]*background: "#f7f7f7"/); + assert.match(routeDetailSource, /position: "sticky"/); + assert.match(routeDetailSource, /right: 0/); + assert.match(routeDetailSource, /boxShadow: "-8px 0 12px rgba\(255, 255, 255, 0\.92\)"/); + assert.match(routeDetailSource, /aria-label=\{`Open actions for \$\{row\.order\}`\}/); + assert.match(routeDetailSource, /aria-haspopup="menu"/); + assert.match(routeDetailSource, /data-child-stop-actions-trigger="true"/); + assert.match(routeDetailSource, /data-child-stop-actions-menu="true"/); + assert.match(routeDetailSource, /role="menu"/); + assert.match(routeDetailSource, />Mark as…<\/div>/); + assert.match(routeDetailSource, /handleMarkChildStopStatus\(activeChildStopActionsRow, "READY"\)/); + assert.match(routeDetailSource, /handleMarkChildStopStatus\(activeChildStopActionsRow, "IN_PROGRESS"\)/); + assert.match(routeDetailSource, /handleMarkChildStopStatus\(activeChildStopActionsRow, "COMPLETED"\)/); + assert.ok(actionsMenuStart >= 0 && actionsMenuEnd > actionsMenuStart); + assert.doesNotMatch(actionsMenuSource, /Attempted/); + assert.match(routeDetailSource, />\s*Edit stop\s*<\/button>/); + assert.match(routeDetailSource, />\s*Remove from group\s*<\/button>/); + assert.match(routeDetailSource, />\s*Send to route\s*<\/button>/); + assert.match(routeDetailSource, />\s*View in Shopify\s*<\/a>/); + assert.match(routeDetailSource, />\s*Open tracking\s*<\/button>/); +}); + +test("child order rows preserve canonical identifiers and flat operational edit fields for actions", () => { + const [row] = buildChildRouteOrderRows([ + { + address: { + address1: "10 Test St", + address2: "Unit 2", + city: "Toronto", + countryCode: "CA", + postalCode: "M1M 1M1", + province: "ON", + }, + coordinates: [-79.4, 43.7], + id: "local-stop-id", + instructions: "Use side door", + deliveryStopId: "delivery-stop-1", + orderId: "canonical-order-1", + orderName: "#1001", + phone: "+14165550123", + recipientName: "Kim Minji", + serviceMinutes: 7, + shopifyOrderGid: "gid://shopify/Order/1001", + shopifyOrderLegacyId: "1001", + timeWindowEnd: "21:00", + timeWindowStart: "17:00", + }, + ]); + + assert.equal(row.orderId, "canonical-order-1"); + assert.equal(row.deliveryStopId, "delivery-stop-1"); + assert.equal(row.shopifyOrderGid, "gid://shopify/Order/1001"); + assert.equal(row.shopifyOrderLegacyId, "1001"); + assert.deepEqual(row.editFields, { + address1: "10 Test St", + address2: "Unit 2", + city: "Toronto", + countryCode: "CA", + instructions: "Use side door", + latitude: 43.7, + longitude: -79.4, + phone: "+14165550123", + postalCode: "M1M 1M1", + province: "ON", + recipientName: "Kim Minji", + serviceMinutes: 7, + timeWindowEnd: "21:00", + timeWindowStart: "17:00", + }); +}); + +test("child stop actions call server intents only for status and CLEVER field edits", () => { + assert.match(routeDetailSource, /submitRouteAction\("transitionRouteStop"/); + assert.match(routeDetailSource, /submitRouteAction\("updateRouteStop"/); + assert.doesNotMatch(routeDetailSource, /\["deliveryArea", "Delivery area"\]/); + assert.doesNotMatch(routeDetailSource, /\["deliveryNote", "Delivery note"\]/); + assert.doesNotMatch(routeDetailSource, /\["serviceType", "Service type"\]/); + assert.match(routeDetailSource, /const canDraftEditChildStopMembership = !isRouteExecutionLockedForStopMembership\(routeExecutionStatus\)/); + assert.match(routeDetailSource, /disabled=\{!canDraftEditChildStopMembership\}/); + assert.match(routeDetailServerSource, /intent === "transitionRouteStop"/); + assert.match(routeDetailServerSource, /transitionDeliveryRoutePlanStop\(\s*request,\s*routeId,\s*deliveryStopId/); + assert.match(routeDetailServerSource, /intent === "updateRouteStop"/); + assert.match(routeDetailServerSource, /updateDeliveryRoutePlanStop\(\s*request,\s*routeId,\s*deliveryStopId/); }); test("child timeline precedes the table and enforces explicit responsive minimum spacing", () => { @@ -204,7 +300,7 @@ test("child timeline renders distinct circular Start and End markers", () => { assert.match(routeDetailSource, />End<\/span>/); assert.match(routeDetailSource, /childRouteTimelineOrderLabelStyle/); assert.match(routeDetailSource, /\{stop\.order\}<\/span>/); - assert.doesNotMatch(routeDetailSource, /position: "sticky"/); + assert.match(routeDetailSource, /const childRouteActionsCellStyle = \{[\s\S]*position: "sticky"/); assert.match(routeDetailSource, /onDragStart=\{\(event\) => handleRouteTimelineDragStart\(event, routeRow, stop\)\}/); assert.match(routeDetailSource, /onClick=\{handleSaveRouteDraft\}/); assert.match(routeDetailSource, /Drop orders here to remove them from the route/); @@ -233,7 +329,7 @@ test("child timeline and order table share explicit centered alignment axes", () assert.match(routeDetailSource, /const childRouteOrderHeaderCellStyle = \{[\s\S]*textAlign: "center"[\s\S]*verticalAlign: "middle"/); assert.match(routeDetailSource, /const childRouteOrderCellStyle = \{[\s\S]*textAlign: "center"/); assert.match(routeDetailSource, /const childRouteStopCellStyle = \{[\s\S]*padding: "8px 0"[\s\S]*textAlign: "center"/); - assert.match(routeDetailSource, /\{column\.label\}<\/th>/); + assert.match(routeDetailSource, /style=\{column\.key === "actions" \? childRouteActionsHeaderCellStyle : childRouteOrderHeaderCellStyle\}/); }); test("child timeline keeps breathing room and stop digits share a browser-neutral optical correction", () => {