diff --git a/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue b/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue
index 80977005..e14fdf88 100644
--- a/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue
+++ b/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue
@@ -182,6 +182,7 @@ const PANEL_ITEMS = [
{ id: "conditions", label: "Conditions", icon: "fas fa-code-branch" },
{ id: "tasks", label: "Tasks", icon: "fas fa-list-check" },
{ id: "hardware", label: "Hardware", icon: "fas fa-microchip" },
+ { id: "pathEditor", label: "Path Editor", icon: "fas fa-pen-to-square" },
{ id: "paths", label: "Path Outcomes", icon: "fas fa-route" },
{ id: "simulator", label: "Simulator", icon: "fas fa-play" },
{ id: "versions", label: "Versions", icon: "fas fa-clock-rotate-left" },
@@ -374,6 +375,29 @@ const pathOutcomesError = ref("");
const pathOutcomesRequestKey = ref("");
const pathOutcomesAbortController = ref(null);
const selectedPathOutcomeId = ref(null);
+const selectedPathCaseSignature = ref("");
+const selectedPathCaseIndex = ref(0);
+const pathCaseRunSignatures = ref({});
+const pathCaseConfirmingSignature = ref("");
+const pathEditorDirty = ref(false);
+const isHydratingPathEditor = ref(false);
+const pathVerificationDynamicImageHidden = ref(false);
+const pathVerificationDynamicImageError = ref("");
+const isPathVerificationDynamicImageLoading = ref(false);
+const pathEditorForm = ref({
+ path_key: "",
+ previous_path_key: "",
+ answers: {},
+ machine_allowed: true,
+ task: "Start machine",
+ description: "",
+ services: ["MACHINE"],
+ program_button: "",
+ press_reset: false,
+ press_start: true,
+ extra_buttons: [],
+ dynamic_images_vehicle_type: "0",
+});
const simulatorCustomerSearchQuery = ref("");
const selectedSimulatorCustomer = ref(null);
const showSimulatorCustomerResults = ref(false);
@@ -950,6 +974,769 @@ const pathResultKey = (path = {}, index = 0) => (
.join("|")}`
);
+const pathEditorQuestionRows = computed(() => {
+ const rows = lookupRows("questions").map((question) => ({
+ id: parseIntOrZero(question.id),
+ label: question.label || question.question || `Question ${question.id}`,
+ order_priority: parseIntOrZero(question.order_priority),
+ })).filter((question) => question.id > 0);
+
+ if (rows.length > 0) {
+ return rows.sort((left, right) => left.order_priority - right.order_priority || left.id - right.id);
+ }
+
+ return flowNodes.value
+ .filter((node) => node.data?.kind === "question")
+ .map((node) => ({
+ id: parseIntOrZero(node.data?.object_id || node.data?.raw?.id),
+ label: node.data?.label || node.data?.raw?.question || node.id,
+ order_priority: parseIntOrZero(node.data?.raw?.order_priority),
+ }))
+ .filter((question) => question.id > 0)
+ .sort((left, right) => left.order_priority - right.order_priority || left.id - right.id);
+});
+
+const pathEditorProgramButtonOptions = computed(() => [
+ { id: 0, value: "0", label: "0 = OFF" },
+ ...Array.from({ length: 12 }, (_, index) => ({
+ id: index + 1,
+ value: String(index + 1),
+ label: `Wheel position #${index + 1}`,
+ })),
+]);
+
+const pathEditorAdditionalButtonOptions = computed(() => taskButtonOptions.filter((option) => (
+ option.id !== "reset"
+ && option.id !== "start"
+ && option.id !== "program_picker"
+)));
+
+const pathEditorServiceOptions = computed(() => {
+ const services = new Map();
+ services.set("MACHINE", { service: "MACHINE", bindings: [] });
+ for (const option of taskServiceOptions.value || []) {
+ services.set(option.service, option);
+ }
+ for (const service of normalizeServiceList(pathEditorForm.value.services)) {
+ if (!services.has(service)) {
+ services.set(service, { service, bindings: [] });
+ }
+ }
+ return Array.from(services.values()).sort((left, right) => left.service.localeCompare(right.service));
+});
+
+const pathConfirmationsSummary = computed(() => {
+ const fromResponse = pathOutcomes.value?.confirmations?.summary || pathOutcomesSummary.value?.confirmations || null;
+ if (fromResponse) {
+ return {
+ confirmed: Number(fromResponse.confirmed || 0),
+ unconfirmed: Number(fromResponse.unconfirmed || 0),
+ stale: Number(fromResponse.stale || 0),
+ removed: Number(fromResponse.removed || 0),
+ total: Number(fromResponse.total || pathResultList.value.length || 0),
+ };
+ }
+
+ const counts = { confirmed: 0, unconfirmed: 0, stale: 0, removed: 0, total: pathResultList.value.length };
+ for (const path of pathResultList.value) {
+ const status = path.confirmation_status || "unconfirmed";
+ if (status === "confirmed") {
+ counts.confirmed += 1;
+ } else if (status === "stale") {
+ counts.stale += 1;
+ } else {
+ counts.unconfirmed += 1;
+ }
+ }
+ return counts;
+});
+
+const pathConfirmationProgressLabel = computed(() => {
+ const total = pathConfirmationsSummary.value.total || 0;
+ if (total <= 0) {
+ return "0 / 0 confirmed";
+ }
+ return `${pathConfirmationsSummary.value.confirmed} / ${total} confirmed`;
+});
+
+const pathCaseList = computed(() => pathResultList.value);
+const removedPathConfirmations = computed(() => (
+ Array.isArray(pathOutcomes.value?.confirmations?.removed) ? pathOutcomes.value.confirmations.removed : []
+));
+
+const pathCaseSignature = (path = {}, index = 0) => path.path_signature || pathResultKey(path, index);
+
+const pathVerificationSelectedCase = computed(() => {
+ const cases = pathCaseList.value;
+ if (cases.length === 0) {
+ return null;
+ }
+
+ if (selectedPathCaseSignature.value) {
+ const selected = cases.find((path, index) => pathCaseSignature(path, index) === selectedPathCaseSignature.value);
+ if (selected) {
+ return selected;
+ }
+ }
+
+ return cases[Math.min(selectedPathCaseIndex.value, cases.length - 1)] || cases[0] || null;
+});
+
+const pathVerificationCasePositionLabel = computed(() => {
+ const total = pathCaseList.value.length;
+ if (total === 0) {
+ return "No cases";
+ }
+ const selectedIndex = pathCaseList.value.findIndex((path, index) => (
+ pathCaseSignature(path, index) === pathCaseSignature(pathVerificationSelectedCase.value, index)
+ ));
+ const caseNumber = selectedIndex >= 0 ? selectedIndex + 1 : Math.min(selectedPathCaseIndex.value + 1, total);
+ return `Case ${caseNumber} of ${total}`;
+});
+
+const pathVerificationSelectedCaseStatus = computed(() => (
+ pathVerificationSelectedCase.value?.confirmation_status || "unconfirmed"
+));
+
+const pathVerificationSelectedCaseStatusLabel = computed(() => (
+ pathCaseConfirmationLabel(pathVerificationSelectedCase.value || {})
+));
+
+const pathVerificationSelectedCaseHasRun = computed(() => Boolean(
+ pathVerificationSelectedCase.value?.path_signature
+ && pathCaseRunSignatures.value[pathVerificationSelectedCase.value.path_signature]
+));
+
+const pathEditorMachineButtonOptions = computed(() => Array.from({ length: 12 }, (_, index) => {
+ const option = taskButtonOptions.find((entry) => entry.id === index);
+ return {
+ id: index,
+ label: option?.description || `Machine button ${index}`,
+ description: option?.name || `Program ${index + 1}`,
+ };
+}));
+
+const normalizePreviewTaskForTaskList = (task = {}, index = 0) => ({
+ ...task,
+ id: task.id ?? task.task_id ?? `path-editor-preview-${index}`,
+ task: task.task || task.label || `Task ${index + 1}`,
+ description: task.description || "",
+ services: normalizeServiceList(task.services || []),
+ buttons: normalizeTaskButtonList(task),
+ attachments: Array.isArray(task.attachments) ? task.attachments : [],
+});
+
+const pathEditorDraftPreviewTasks = computed(() => {
+ if (!pathEditorForm.value.machine_allowed) {
+ return [];
+ }
+
+ return pathEditorTaskSequence.value.map(normalizePreviewTaskForTaskList);
+});
+
+const pathVerificationPreviewTasks = computed(() => {
+ if (pathVerificationSelectedCaseHasRun.value && simulatorPreviewTasks.value.length > 0) {
+ return simulatorPreviewTasks.value.map(normalizePreviewTaskForTaskList);
+ }
+ return pathEditorDraftPreviewTasks.value;
+});
+
+const pathVerificationPreviewSourceLabel = computed(() => (
+ pathVerificationSelectedCaseHasRun.value ? "Simulator result" : "Expected result"
+));
+
+const pathVerificationDynamicImageButtons = computed(() => (
+ getSelfServeDynamicImageButtonsToPress(pathVerificationPreviewTasks.value)
+));
+
+const pathVerificationDynamicImageThumbPosition = computed(() => (
+ getSelfServeDynamicImageThumbPosition(pathVerificationPreviewTasks.value)
+));
+
+const pathVerificationHasDynamicImageContext = computed(() => (
+ pathVerificationDynamicImageButtons.value.length > 0 || pathVerificationDynamicImageThumbPosition.value !== null
+));
+
+const pathVerificationDynamicImageUrl = computed(() => {
+ if (!pathVerificationHasDynamicImageContext.value) {
+ return null;
+ }
+ const laneId = parseIntOrZero(selectedSimulatorLaneId.value);
+ const dynamicImageId = parseNullableInt(selectedSimulatorLaneRow.value?.dynamic_image_id);
+ if (!departmentId.value || laneId <= 0 || !dynamicImageId) {
+ return null;
+ }
+
+ return buildSelfServeDynamicImageUrl({
+ departmentId: departmentId.value,
+ laneId,
+ dynamicImageId,
+ buttons: pathVerificationDynamicImageButtons.value,
+ currentStep: 0,
+ vehicleTypeId: selectedSimulatorVehicleTypeId.value,
+ thumbPosition: pathVerificationDynamicImageThumbPosition.value,
+ });
+});
+
+const displayedPathVerificationDynamicImageUrl = computed(() => (
+ pathVerificationDynamicImageHidden.value ? null : pathVerificationDynamicImageUrl.value
+));
+
+const pathVerificationDynamicImageUnavailableReason = computed(() => {
+ if (pathVerificationDynamicImageError.value) {
+ return pathVerificationDynamicImageError.value;
+ }
+ if (!pathEditorForm.value.machine_allowed) {
+ return "Machine wash is blocked for this case.";
+ }
+ if (parseIntOrZero(selectedSimulatorLaneId.value) <= 0) {
+ return "Select a lane before previewing the machine image.";
+ }
+ if (!parseNullableInt(selectedSimulatorLaneRow.value?.dynamic_image_id)) {
+ return "The selected lane has no dynamic image configured.";
+ }
+ if (!pathVerificationHasDynamicImageContext.value) {
+ return "Select a machine button, reset/start, program wheel, or vehicle marker to preview the image.";
+ }
+ if (pathVerificationDynamicImageHidden.value) {
+ return "Dynamic image could not be rendered for the selected case.";
+ }
+ return "Dynamic image preview is unavailable.";
+});
+
+const pathEditorProgramWheelPosition = computed(() => {
+ const position = parseNullableInt(pathEditorForm.value.dynamic_images_vehicle_type);
+ return Number.isInteger(position) && position >= 1 && position <= 12 ? position : null;
+});
+
+const pathEditorSelectedMachineButtonIds = computed(() => {
+ const currentButtons = Array.isArray(pathEditorForm.value.extra_buttons)
+ ? pathEditorForm.value.extra_buttons
+ : [];
+ return pathEditorMachineButtonOptions.value
+ .map((option) => option.id)
+ .filter((buttonId) => currentButtons.some((button) => String(button) === String(buttonId)));
+});
+
+const pathEditorPhysicalButtons = computed(() => {
+ const buttons = [];
+ if (pathEditorForm.value.press_reset) {
+ buttons.push("reset");
+ }
+ buttons.push(...pathEditorSelectedMachineButtonIds.value);
+ if (pathEditorForm.value.press_start) {
+ buttons.push("start");
+ }
+ return normalizeSelfServeTaskButtons(buttons);
+});
+
+const pathVerificationButtonSummary = computed(() => (
+ [
+ pathEditorProgramWheelPosition.value
+ ? `Program wheel #${pathEditorProgramWheelPosition.value}`
+ : "Program wheel off",
+ formatSelfServeTaskButtons(pathEditorPhysicalButtons.value, "No physical machine buttons"),
+ ].join(" / ")
+));
+
+const pathCaseStatusClass = (status) => ({
+ "is-confirmed": status === "confirmed",
+ "is-stale": status === "stale",
+ "is-unconfirmed": !status || status === "unconfirmed",
+});
+
+const pathCaseConfirmationLabel = (path = {}) => {
+ if (path.confirmation_status === "confirmed") {
+ return "Confirmed";
+ }
+ if (path.confirmation_status === "stale") {
+ return "Stale";
+ }
+ return "Unconfirmed";
+};
+
+const pathCaseAnswersLabel = (path = {}) => {
+ const answers = Array.isArray(path.answers) ? path.answers : [];
+ if (answers.length === 0) {
+ return "No answers";
+ }
+ return answers.map((answer) => `${answer.question || `Question ${answer.question_id}`}: ${pathOutcomeAnswerLabel(answer)}`).join(" / ");
+};
+
+const pathCaseShortAnswers = (path = {}) => {
+ const answers = Array.isArray(path.answers) ? path.answers : [];
+ if (answers.length === 0) {
+ return "No answers selected";
+ }
+ return answers.map((answer) => `${answer.question || `Question ${answer.question_id}`}: ${pathOutcomeAnswerLabel(answer)}`).join(", ");
+};
+
+const pathCaseResultLabel = (path = {}) => {
+ const tasks = Array.isArray(path.tasks) ? path.tasks : [];
+ const buttons = tasks.flatMap((task) => normalizeButtonList(task.buttons || []));
+ const buttonLabel = buttons.length > 0 ? ` / ${formatSelfServeTaskButtons(buttons)}` : "";
+ return `${path.allowed ? "Allowed" : "Blocked"} / ${pathOutcomeServicesLabel(path.services)} / ${pathResultMetricsLabel(path)}${buttonLabel}`;
+};
+
+const pathEditorAnswerState = (questionId) => {
+ const value = pathEditorForm.value.answers?.[questionId];
+ if (value === true) {
+ return "yes";
+ }
+ if (value === false) {
+ return "no";
+ }
+ return "unset";
+};
+
+const pathEditorAnswerStateLabel = (questionId) => {
+ const state = pathEditorAnswerState(questionId);
+ if (state === "yes") {
+ return "Yes";
+ }
+ if (state === "no") {
+ return "No";
+ }
+ return "Unset";
+};
+
+const setPathEditorAnswer = (questionId, value) => {
+ pathEditorForm.value = {
+ ...pathEditorForm.value,
+ answers: {
+ ...pathEditorForm.value.answers,
+ [questionId]: value,
+ },
+ };
+};
+
+const togglePathEditorAnswer = (questionId) => {
+ setPathEditorAnswer(questionId, pathEditorForm.value.answers?.[questionId] !== true);
+};
+
+const pathEditorAnswerButtonClass = (questionId, value) => ({
+ "is-primary": pathEditorForm.value.answers?.[questionId] === value,
+ "is-light": pathEditorForm.value.answers?.[questionId] !== value,
+});
+
+const pathEditorNumberButtonSelected = (buttonId) => (
+ pathEditorPhysicalButtons.value.includes(buttonId)
+);
+
+const togglePathEditorNumberButton = (buttonId) => {
+ const normalizedButtonId = parseIntOrZero(buttonId);
+ const currentButtons = Array.isArray(pathEditorForm.value.extra_buttons)
+ ? pathEditorForm.value.extra_buttons
+ : [];
+ const hasButton = currentButtons.some((button) => String(button) === String(normalizedButtonId));
+ pathEditorForm.value = {
+ ...pathEditorForm.value,
+ extra_buttons: hasButton
+ ? currentButtons.filter((button) => String(button) !== String(normalizedButtonId))
+ : [...currentButtons, normalizedButtonId],
+ };
+};
+
+const pathEditorNumberButtonClass = (buttonId) => ({
+ "is-selected": pathEditorNumberButtonSelected(buttonId),
+});
+
+const pathEditorSelectedButtons = computed(() => {
+ const buttons = [];
+ if (pathEditorProgramWheelPosition.value) {
+ buttons.push("program_picker");
+ }
+ buttons.push(...pathEditorPhysicalButtons.value);
+ return normalizeSelfServeTaskButtons(buttons);
+});
+
+const pathEditorServiceList = computed(() => {
+ const services = normalizeServiceList(pathEditorForm.value.services);
+ if (pathEditorForm.value.machine_allowed && !services.includes("MACHINE")) {
+ services.push("MACHINE");
+ }
+ if (
+ pathEditorForm.value.machine_allowed
+ && pathEditorProgramWheelPosition.value
+ && !services.includes("PROGRAM_PICKER")
+ ) {
+ services.push("PROGRAM_PICKER");
+ }
+ return services;
+});
+
+const pathEditorMachineControlServices = computed(() => {
+ const services = pathEditorServiceList.value.filter((service) => service !== "PROGRAM_PICKER");
+ if (!services.includes("MACHINE")) {
+ services.unshift("MACHINE");
+ }
+ return services;
+});
+
+const pathEditorTaskSequence = computed(() => {
+ if (!pathEditorForm.value.machine_allowed) {
+ return [];
+ }
+
+ const tasks = [];
+ const programWheelPosition = pathEditorProgramWheelPosition.value;
+ if (programWheelPosition) {
+ tasks.push({
+ id: "path-editor-preview-program-picker",
+ task: pathEditorForm.value.task || "Start machine",
+ description: pathEditorForm.value.description || "",
+ services: pathEditorServiceList.value,
+ buttons: ["program_picker"],
+ dynamic_images_vehicle_type: programWheelPosition,
+ });
+ }
+
+ if (pathEditorForm.value.press_reset) {
+ tasks.push({
+ id: "path-editor-preview-reset",
+ task: "Press reset",
+ description: "",
+ services: pathEditorMachineControlServices.value,
+ buttons: ["reset"],
+ dynamic_images_vehicle_type: null,
+ });
+ }
+
+ for (const buttonId of pathEditorSelectedMachineButtonIds.value) {
+ tasks.push({
+ id: `path-editor-preview-machine-button-${buttonId}`,
+ task: `Press machine button ${buttonId}`,
+ description: "",
+ services: pathEditorMachineControlServices.value,
+ buttons: [buttonId],
+ dynamic_images_vehicle_type: null,
+ });
+ }
+
+ if (pathEditorForm.value.press_start) {
+ tasks.push({
+ id: "path-editor-preview-start",
+ task: "Press start",
+ description: "",
+ services: pathEditorMachineControlServices.value,
+ buttons: ["start"],
+ dynamic_images_vehicle_type: null,
+ });
+ }
+
+ return tasks;
+});
+
+const pathEditorTaskPayloads = computed(() => pathEditorTaskSequence.value.map((task) => ({
+ task: task.task,
+ description: task.description,
+ services: task.services,
+ buttons: task.buttons,
+ dynamic_images_vehicle_type: task.dynamic_images_vehicle_type,
+})));
+
+const pathEditorAnswerPayload = () => Object.entries(pathEditorForm.value.answers || {})
+ .filter(([, value]) => value === true || value === false)
+ .map(([questionId, value]) => ({ question_id: parseIntOrZero(questionId), value }))
+ .filter((answer) => answer.question_id > 0);
+
+const resetPathEditorForm = () => {
+ isHydratingPathEditor.value = true;
+ pathEditorForm.value = {
+ path_key: "",
+ previous_path_key: "",
+ answers: {},
+ machine_allowed: true,
+ task: "Start machine",
+ description: "",
+ services: ["MACHINE"],
+ program_button: "",
+ press_reset: false,
+ press_start: true,
+ extra_buttons: [],
+ dynamic_images_vehicle_type: "0",
+ };
+ selectedPathCaseSignature.value = "";
+ selectedPathCaseIndex.value = 0;
+ nextTick(() => {
+ pathEditorDirty.value = false;
+ isHydratingPathEditor.value = false;
+ });
+};
+
+const loadPathCaseIntoEditor = (path = {}, index = null) => {
+ const answers = {};
+ for (const answer of (Array.isArray(path.answers) ? path.answers : [])) {
+ answers[answer.question_id] = answer.answer === true;
+ }
+ const pathTasks = Array.isArray(path.tasks) ? path.tasks : [];
+ const flattenedButtons = normalizeSelfServeTaskButtons(pathTasks.flatMap((task) => normalizeButtonList(task?.buttons || [])));
+ const taskServices = pathTasks.flatMap((task) => (Array.isArray(task?.services) ? task.services : []));
+ const pathServices = normalizeServiceList(path.services || []);
+ const firstTask = pathTasks[0] || null;
+ const programTask = pathTasks.find((task) => {
+ const services = normalizeServiceList(task?.services || []);
+ const buttons = normalizeButtonList(task?.buttons || []);
+ return services.includes("PROGRAM_PICKER") || buttons.includes("program_picker");
+ }) || null;
+ const programTaskButtons = normalizeButtonList(programTask?.buttons || []);
+ const firstTaskServices = pathServices.length > 0
+ ? pathServices
+ : normalizeServiceList(taskServices.length > 0 ? taskServices : firstTask?.services || []);
+ const usesProgramPicker = firstTaskServices.includes("PROGRAM_PICKER") || flattenedButtons.includes("program_picker");
+ const legacyProgramButton = usesProgramPicker ? flattenedButtons.find((button) => typeof button === "number") : null;
+ const wheelPosition = usesProgramPicker
+ ? (parseNullableInt(programTask?.dynamic_images_vehicle_type) || (Number.isInteger(legacyProgramButton) ? legacyProgramButton + 1 : 0))
+ : 0;
+
+ isHydratingPathEditor.value = true;
+ pathEditorForm.value = {
+ ...pathEditorForm.value,
+ path_key: path.path_key || "",
+ previous_path_key: path.path_key || "",
+ answers,
+ machine_allowed: Boolean(path.allowed),
+ task: programTask?.label || programTask?.task || firstTask?.label || firstTask?.task || "Start machine",
+ description: programTask?.description || firstTask?.description || "",
+ services: firstTaskServices.length > 0 ? firstTaskServices : ["MACHINE"],
+ program_button: "",
+ press_reset: flattenedButtons.includes("reset"),
+ press_start: flattenedButtons.includes("start"),
+ extra_buttons: flattenedButtons.filter((button) => (
+ button !== "reset"
+ && button !== "start"
+ && button !== "program_picker"
+ && !(usesProgramPicker && !parseNullableInt(programTask?.dynamic_images_vehicle_type) && programTaskButtons.includes(button) && button === legacyProgramButton)
+ )),
+ dynamic_images_vehicle_type: wheelPosition ? String(wheelPosition) : "0",
+ };
+ const resolvedIndex = Number.isInteger(index)
+ ? index
+ : pathCaseList.value.findIndex((entry, entryIndex) => pathCaseSignature(entry, entryIndex) === pathCaseSignature(path, entryIndex));
+ selectedPathCaseIndex.value = resolvedIndex >= 0 ? resolvedIndex : selectedPathCaseIndex.value;
+ selectedPathCaseSignature.value = pathCaseSignature(path, selectedPathCaseIndex.value);
+ nextTick(() => {
+ pathEditorDirty.value = false;
+ isHydratingPathEditor.value = false;
+ });
+};
+
+const selectPathCase = (path = {}, index = null) => {
+ loadPathCaseIntoEditor(path, index);
+};
+
+const savePathEditorPath = async () => {
+ if (!requiredScopeComplete.value) {
+ openScopeGuide();
+ toast.error("Select a lane and vehicle type before saving a path.");
+ return;
+ }
+
+ const answers = pathEditorAnswerPayload();
+ if (answers.length === 0) {
+ toast.error("Select at least one answer for this path.");
+ return;
+ }
+
+ const graph = await saveGraphOperations([{
+ action: "upsert_path",
+ entity: "path",
+ data: {
+ path_key: pathEditorForm.value.path_key || undefined,
+ previous_path_key: pathEditorForm.value.previous_path_key || undefined,
+ scope: {
+ lane_id: parseNullableInt(selectedSimulatorLaneId.value),
+ vehicle_type_id: parseNullableInt(selectedSimulatorVehicleTypeId.value),
+ machine_type_id: parseNullableInt(filters.value.machine_type_id) || parseNullableInt(selectedSimulatorLaneRow.value?.machine_type_id),
+ config_source: simulatorForm.value.config_source || "draft",
+ hardware_mode: simulatorForm.value.hardware_mode || "studio",
+ },
+ answers,
+ result: {
+ machine_allowed: Boolean(pathEditorForm.value.machine_allowed),
+ task: pathEditorForm.value.task || "Start machine",
+ description: pathEditorForm.value.description || "",
+ services: pathEditorForm.value.machine_allowed ? pathEditorServiceList.value : [],
+ buttons: pathEditorForm.value.machine_allowed ? pathEditorSelectedButtons.value : [],
+ dynamic_images_vehicle_type: pathEditorProgramWheelPosition.value,
+ tasks: pathEditorForm.value.machine_allowed ? pathEditorTaskPayloads.value : [],
+ },
+ },
+ }]);
+
+ if (!graph) {
+ return;
+ }
+ pathOutcomes.value = null;
+ pathOutcomesRequestKey.value = "";
+ await loadPathOutcomes({ force: true });
+ pathEditorDirty.value = false;
+};
+
+const pathCaseAnswerOverrides = (path = {}) => {
+ const overrides = {};
+ for (const answer of (Array.isArray(path.answers) ? path.answers : [])) {
+ overrides[answer.question_id] = answer.answer === true;
+ }
+ return overrides;
+};
+
+const markPathCaseRun = (path = {}) => {
+ const signature = path.path_signature || "";
+ if (!signature) {
+ return;
+ }
+ pathCaseRunSignatures.value = {
+ ...pathCaseRunSignatures.value,
+ [signature]: true,
+ };
+};
+
+const runPathCaseInPlace = async (path = pathVerificationSelectedCase.value) => {
+ if (!path) {
+ toast.error("Select a case to run.");
+ return false;
+ }
+ simulatorAnswerOverrides.value = pathCaseAnswerOverrides(path);
+ const didRun = await runSimulator({ manual: true, reason: "path verification" });
+ if (didRun) {
+ markPathCaseRun(path);
+ }
+ return didRun;
+};
+
+const openPathCaseInSimulator = async (path = {}) => {
+ selectPathCase(path);
+ const overrides = pathCaseAnswerOverrides(path);
+ simulatorAnswerOverrides.value = overrides;
+ activePanel.value = "simulator";
+ await nextTick();
+ const didRun = await runSimulator({ manual: true, reason: "path case" });
+ if (didRun) {
+ markPathCaseRun(path);
+ }
+};
+
+const pathCaseCanConfirm = (path = {}) => Boolean(
+ path.path_signature
+ && path.result_signature
+ && pathCaseRunSignatures.value[path.path_signature]
+ && path.confirmation_status !== "confirmed"
+);
+
+const confirmPathCase = async (path = {}) => {
+ if (!path.path_signature || !path.result_signature) {
+ toast.error("This path is missing confirmation signatures. Refresh cases and try again.");
+ return false;
+ }
+ pathCaseConfirmingSignature.value = path.path_signature;
+ try {
+ await requestPost("/department/selfserve/studio/path-confirmations", {
+ department: departmentId.value,
+ path_signature: path.path_signature,
+ result_signature: path.result_signature,
+ scope: path.scope || pathOutcomesScope.value || {},
+ answers: path.answers || [],
+ result: {
+ allowed: Boolean(path.allowed),
+ services: path.services || [],
+ tasks: path.tasks || [],
+ signals: path.signals || [],
+ },
+ });
+ toast.success("Path confirmed.");
+ await loadPathOutcomes({ force: true });
+ return true;
+ } catch (error) {
+ withErrorToast(error, "Could not confirm path.");
+ return false;
+ } finally {
+ pathCaseConfirmingSignature.value = "";
+ }
+};
+
+const selectNextPathCase = (fromSignature = selectedPathCaseSignature.value) => {
+ const cases = pathCaseList.value;
+ if (cases.length === 0) {
+ return;
+ }
+ const currentIndex = Math.max(0, cases.findIndex((path, index) => pathCaseSignature(path, index) === fromSignature));
+ const nextIncompleteIndex = cases.findIndex((path, index) => (
+ index > currentIndex && path.confirmation_status !== "confirmed"
+ ));
+ const nextIndex = nextIncompleteIndex >= 0
+ ? nextIncompleteIndex
+ : Math.min(currentIndex + 1, cases.length - 1);
+ selectPathCase(cases[nextIndex], nextIndex);
+};
+
+const selectPreviousPathCase = () => {
+ const cases = pathCaseList.value;
+ if (cases.length === 0) {
+ return;
+ }
+ const currentIndex = cases.findIndex((path, index) => (
+ pathCaseSignature(path, index) === pathCaseSignature(pathVerificationSelectedCase.value, index)
+ ));
+ const previousIndex = Math.max(0, (currentIndex >= 0 ? currentIndex : selectedPathCaseIndex.value) - 1);
+ selectPathCase(cases[previousIndex], previousIndex);
+};
+
+const confirmCurrentPathAndNext = async () => {
+ const path = pathVerificationSelectedCase.value;
+ if (!path) {
+ toast.error("Select a case before confirming.");
+ return;
+ }
+ if (path.confirmation_status === "confirmed") {
+ selectNextPathCase(pathCaseSignature(path, selectedPathCaseIndex.value));
+ return;
+ }
+ if (pathEditorDirty.value) {
+ toast.error("Save the path before confirming this case.");
+ return;
+ }
+ const didRun = pathVerificationSelectedCaseHasRun.value || await runPathCaseInPlace(path);
+ if (!didRun) {
+ return;
+ }
+ const confirmedSignature = pathCaseSignature(path, selectedPathCaseIndex.value);
+ const didConfirm = await confirmPathCase(path);
+ if (didConfirm) {
+ selectNextPathCase(confirmedSignature);
+ }
+};
+
+const ensurePathVerificationSelection = () => {
+ const cases = pathCaseList.value;
+ if (cases.length === 0) {
+ selectedPathCaseIndex.value = 0;
+ selectedPathCaseSignature.value = "";
+ return;
+ }
+
+ const selectedIndex = cases.findIndex((path, index) => pathCaseSignature(path, index) === selectedPathCaseSignature.value);
+ if (selectedIndex >= 0) {
+ selectedPathCaseIndex.value = selectedIndex;
+ return;
+ }
+
+ selectedPathCaseIndex.value = Math.min(selectedPathCaseIndex.value, cases.length - 1);
+ if (!pathEditorDirty.value) {
+ loadPathCaseIntoEditor(cases[selectedPathCaseIndex.value], selectedPathCaseIndex.value);
+ }
+};
+
+const onPathVerificationDynamicImageError = () => {
+ isPathVerificationDynamicImageLoading.value = false;
+ pathVerificationDynamicImageHidden.value = true;
+ pathVerificationDynamicImageError.value = "Dynamic image endpoint could not render this case preview.";
+};
+
+const onPathVerificationDynamicImageLoad = () => {
+ isPathVerificationDynamicImageLoading.value = false;
+};
+
const selectPathOutcome = (outcome) => {
selectedPathOutcomeId.value = outcome?.id || null;
};
@@ -964,8 +1751,9 @@ const focusPathOutcomeNodes = async (nodeIds = []) => {
focusDebugNodes(ids, false);
};
-const loadPathOutcomes = async () => {
- if (!departmentId.value || isLoadingPathOutcomes.value) {
+const loadPathOutcomes = async (options = {}) => {
+ const force = options?.force === true;
+ if (!departmentId.value || (isLoadingPathOutcomes.value && !force)) {
return;
}
if (!requiredScopeComplete.value) {
@@ -989,6 +1777,7 @@ const loadPathOutcomes = async () => {
if (!selectedPathOutcomeId.value || event.type === "complete") {
selectedPathOutcomeId.value = pathOutcomeList.value[0]?.id || null;
}
+ ensurePathVerificationSelection();
}
},
abortController.signal
@@ -996,6 +1785,7 @@ const loadPathOutcomes = async () => {
pathOutcomes.value = payload || { outcomes: [], paths: [], summary: {}, warnings: [], truncated: false, progress: { complete: true, percent: 100 } };
pathOutcomesRequestKey.value = requestKey;
selectedPathOutcomeId.value = pathOutcomeList.value[0]?.id || null;
+ ensurePathVerificationSelection();
} catch (error) {
if (error?.name === "AbortError") {
return;
@@ -3608,7 +4398,7 @@ const runSimulator = async ({ manual = false, reason = "manual" } = {}) => {
if (manual) {
toast.error(simulatorLastError.value);
}
- return;
+ return false;
}
const requestKey = JSON.stringify(payload);
@@ -3621,18 +4411,20 @@ const runSimulator = async ({ manual = false, reason = "manual" } = {}) => {
const result = await requestPost("/department/selfserve/studio/simulate", payload);
if (sequence !== simulatorRequestSequence.value || requestKey !== simulatorLastRequestKey.value) {
- return;
+ return false;
}
simulatorResult.value = result;
simulatorDynamicImageError.value = "";
applyCanvasFilters();
+ return true;
} catch (error) {
const message = simulatorErrorMessage(error);
simulatorLastError.value = message;
if (manual) {
toast.error(message);
}
+ return false;
} finally {
if (sequence === simulatorRequestSequence.value) {
isSimulatorRunning.value = false;
@@ -3985,10 +4777,16 @@ watch(() => currentRoute?.fullPath, () => {
syncScopeFromRoute();
}, { immediate: true });
watch(activePanel, (panel) => {
- if (panel === "paths" && !pathOutcomes.value && !pathOutcomesError.value) {
+ if ((panel === "paths" || panel === "pathEditor") && !pathOutcomes.value && !pathOutcomesError.value) {
loadPathOutcomes();
}
}, { immediate: true });
+watch(pathCaseList, ensurePathVerificationSelection);
+watch(pathEditorForm, () => {
+ if (!isHydratingPathEditor.value) {
+ pathEditorDirty.value = true;
+ }
+}, { deep: true });
watch(isStudioFullscreen, refitStudioCanvas);
watch(filters, applyCanvasFilters, { deep: true });
watch(searchTerm, applyCanvasFilters);
@@ -4005,6 +4803,13 @@ watch(simulatorDynamicImageUrl, () => {
simulatorDynamicImageHidden.value = false;
simulatorDynamicImageError.value = "";
});
+watch(pathVerificationDynamicImageUrl, () => {
+ pathVerificationDynamicImageHidden.value = false;
+ pathVerificationDynamicImageError.value = "";
+});
+watch(displayedPathVerificationDynamicImageUrl, (dynamicImageUrl) => {
+ isPathVerificationDynamicImageLoading.value = Boolean(dynamicImageUrl);
+}, { immediate: true });
watch(inspectorTaskDynamicImageUrl, () => {
inspectorTaskDynamicImageHidden.value = false;
inspectorTaskDynamicImageError.value = "";
@@ -4079,7 +4884,7 @@ onBeforeUnmount(() => {
@@ -4415,6 +5220,353 @@ onBeforeUnmount(() => {
+
+
+
+
+
+ {{ pathOutcomesError }}
+
+
+
+
{{ pathConfirmationProgressLabel }}Total progress
+
{{ pathConfirmationsSummary.unconfirmed }}Unconfirmed
+
{{ pathConfirmationsSummary.stale }}Stale
+
{{ pathConfirmationsSummary.removed }}Removed
+
+
+
+
+
+
+
+ No cases loaded
+
+
+
+
+ Projecting cases
+
+
+
+
+
+
+ {{ question.label }}
+
+
+
+
+
+
+
No questions available.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No cases loaded
+
+
+
+
+ Projecting cases
+
+
+
+
+ {{ pathCaseAnswersLabel(path) }}
+ {{ path.stale_reason }}
+
+
+
+
+
+
+
+
+
+ {{ pathCaseAnswersLabel(removed) }}
+
+
No terminal cases were projected.
+
+
+
+
+
-