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) => (
-
@@ -5081,6 +5476,24 @@ export default function RouteDetailPage() {
{renderChildRouteInfoIcon()}
+ {column.label}
+
+ {column.label}
+
))}