Refactor machine allowance logic in self-serve studio and remove unused controls

This commit is contained in:
Jeppe Bundgaard
2026-06-09 14:52:20 +02:00
parent e1a824d565
commit 66f4331b4e
5 changed files with 119 additions and 156 deletions
+1 -6
View File
@@ -3,7 +3,6 @@ import { computed, ref, watch } from "vue";
import axios from "axios";
import { API_URL } from "@/config.js";
import { getScansDepartmentPagination } from "@/components/numberplatescanners/Scans.vue";
import { useRouter } from "vue-router";
import { parseError, clearErrors } from "@/components/request/HandleGlobalError.vue";
import { getNotes, createNote, deleteNote } from "@/components/shop/CustomerNotes.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
@@ -231,11 +230,7 @@ const toPositiveInteger = (value) => {
};
const getRouteDepartmentId = () => {
try {
return toPositiveInteger(useRouter().currentRoute.value.params.departmentId);
} catch (error) {
return null;
}
return getWindowPathDepartmentId();
};
const getSessionUrlDepartmentId = () => {
@@ -1,13 +1,26 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const emits = defineEmits(['camera-toggled', 'scanner-toggled', 'update:frame']);
const { t } = useI18n();
const videoRef = ref<HTMLVideoElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const cameraStream = ref<MediaStream | null>(null);
const isCameraActive = ref(false);
const cameraErrorKey = ref('pos.camera_permission_denied');
import { isCameraMounted, camera } from '@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue';
const getCameraErrorKey = (err: unknown) => {
const errorName = err instanceof DOMException ? err.name : '';
if (errorName === 'NotFoundError' || errorName === 'DevicesNotFoundError') {
return 'pos.no_camera_found';
}
return 'pos.camera_permission_denied';
};
function startCamera() {
const constraints = {
video: {
@@ -45,6 +58,7 @@ function startCamera() {
})
.catch((err) => {
isCameraActive.value = false;
cameraErrorKey.value = getCameraErrorKey(err);
console.error('Camera access error:', err);
});
}
@@ -171,8 +185,7 @@ watch(() => isCameraActive.value, (newVal) => {
></canvas>
<div v-if="!isCameraActive" class="camera-inactive">
<p>Camera is not active.</p>
<p>Please enable camera access.</p>
<p>{{ t(cameraErrorKey) }}</p>
</div>
</div>
</template>
@@ -422,13 +422,10 @@ const pathEditorForm = ref({
path_key: "",
previous_path_key: "",
answers: {},
machine_allowed: true,
machine_allowed: false,
task: "Start machine",
description: "",
services: ["MACHINE"],
program_button: "",
press_reset: false,
press_start: true,
extra_buttons: [],
dynamic_images_vehicle_type: "0",
});
@@ -1278,20 +1275,6 @@ 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 counts = {
confirmed: 0,
@@ -1555,8 +1538,15 @@ const normalizePreviewTaskForTaskList = (task = {}, index = 0) => ({
attachments: Array.isArray(task.attachments) ? task.attachments : [],
});
const pathEditorProgramWheelPosition = computed(() => {
const position = parseNullableInt(pathEditorForm.value.dynamic_images_vehicle_type);
return Number.isInteger(position) && position >= 1 && position <= 12 ? position : null;
});
const pathEditorMachineAllowed = computed(() => Boolean(pathEditorProgramWheelPosition.value));
const pathEditorDraftPreviewTasks = computed(() => {
if (!pathEditorForm.value.machine_allowed) {
if (!pathEditorMachineAllowed.value) {
return [];
}
@@ -1615,7 +1605,7 @@ const pathVerificationDynamicImageUnavailableReason = computed(() => {
if (pathVerificationDynamicImageError.value) {
return pathVerificationDynamicImageError.value;
}
if (!pathEditorForm.value.machine_allowed) {
if (!pathEditorMachineAllowed.value) {
return "Machine wash is blocked for this case.";
}
if (parseIntOrZero(selectedSimulatorLaneId.value) <= 0) {
@@ -1633,11 +1623,6 @@ const pathVerificationDynamicImageUnavailableReason = computed(() => {
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
@@ -1646,14 +1631,14 @@ const pathEditorSelectedMachineButtonIds = computed(() => {
});
const pathEditorPhysicalButtons = computed(() => {
if (!pathEditorMachineAllowed.value) {
return [];
}
const buttons = [];
if (pathEditorForm.value.press_reset) {
buttons.push("reset");
}
buttons.push("reset");
buttons.push(...pathEditorSelectedMachineButtonIds.value);
if (pathEditorForm.value.press_start) {
buttons.push("start");
}
buttons.push("start");
return normalizeSelfServeTaskButtons(buttons);
});
@@ -1780,18 +1765,10 @@ const pathEditorSelectedButtons = computed(() => {
});
const pathEditorServiceList = computed(() => {
const services = normalizeServiceList(pathEditorForm.value.services);
if (pathEditorForm.value.machine_allowed && !services.includes("MACHINE")) {
services.push("MACHINE");
if (!pathEditorMachineAllowed.value) {
return [];
}
if (
pathEditorForm.value.machine_allowed &&
pathEditorProgramWheelPosition.value &&
!services.includes("PROGRAM_PICKER")
) {
services.push("PROGRAM_PICKER");
}
return services;
return ["MACHINE", "PROGRAM_PICKER"];
});
const pathEditorMachineControlServices = computed(() => {
@@ -1803,7 +1780,7 @@ const pathEditorMachineControlServices = computed(() => {
});
const pathEditorTaskSequence = computed(() => {
if (!pathEditorForm.value.machine_allowed) {
if (!pathEditorMachineAllowed.value) {
return [];
}
@@ -1820,16 +1797,14 @@ const pathEditorTaskSequence = computed(() => {
});
}
if (pathEditorForm.value.press_reset) {
tasks.push({
id: "path-editor-preview-reset",
task: pathEditorTaskTitleForKey("reset", "Press reset"),
description: "",
services: pathEditorMachineControlServices.value,
buttons: ["reset"],
dynamic_images_vehicle_type: null,
});
}
tasks.push({
id: "path-editor-preview-reset",
task: pathEditorTaskTitleForKey("reset", "Press reset"),
description: "",
services: pathEditorMachineControlServices.value,
buttons: ["reset"],
dynamic_images_vehicle_type: null,
});
for (const buttonId of pathEditorSelectedMachineButtonIds.value) {
tasks.push({
@@ -1842,16 +1817,14 @@ const pathEditorTaskSequence = computed(() => {
});
}
if (pathEditorForm.value.press_start) {
tasks.push({
id: "path-editor-preview-start",
task: pathEditorTaskTitleForKey("start", "Press start"),
description: "",
services: pathEditorMachineControlServices.value,
buttons: ["start"],
dynamic_images_vehicle_type: null,
});
}
tasks.push({
id: "path-editor-preview-start",
task: pathEditorTaskTitleForKey("start", "Press start"),
description: "",
services: pathEditorMachineControlServices.value,
buttons: ["start"],
dynamic_images_vehicle_type: null,
});
return tasks;
});
@@ -1878,13 +1851,10 @@ const resetPathEditorForm = () => {
path_key: "",
previous_path_key: "",
answers: {},
machine_allowed: true,
machine_allowed: false,
task: "Start machine",
description: "",
services: ["MACHINE"],
program_button: "",
press_reset: false,
press_start: true,
extra_buttons: [],
dynamic_images_vehicle_type: "0",
};
@@ -1913,18 +1883,25 @@ const loadPathCaseIntoEditor = (path = {}, index = null) => {
pathTasks.find((task) => {
const services = normalizeServiceList(task?.services || []);
const buttons = normalizeButtonList(task?.buttons || []);
return services.includes("PROGRAM_PICKER") || buttons.includes("program_picker");
return (
services.includes("PROGRAM_PICKER") ||
buttons.includes("program_picker") ||
Boolean(parseNullableInt(task?.dynamic_images_vehicle_type))
);
}) || null;
const programTaskButtons = normalizeButtonList(programTask?.buttons || []);
const programTaskWheelPosition = parseNullableInt(programTask?.dynamic_images_vehicle_type);
const firstTaskServices =
pathServices.length > 0
? pathServices
: normalizeServiceList(taskServices.length > 0 ? taskServices : firstTask?.services || []);
const usesProgramPicker = firstTaskServices.includes("PROGRAM_PICKER") || flattenedButtons.includes("program_picker");
const usesProgramPicker =
firstTaskServices.includes("PROGRAM_PICKER") ||
flattenedButtons.includes("program_picker") ||
Boolean(programTaskWheelPosition);
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)
? programTaskWheelPosition || (Number.isInteger(legacyProgramButton) ? legacyProgramButton + 1 : 0)
: 0;
isHydratingPathEditor.value = true;
@@ -1933,13 +1910,10 @@ const loadPathCaseIntoEditor = (path = {}, index = null) => {
path_key: path.path_key || "",
previous_path_key: path.path_key || "",
answers,
machine_allowed: Boolean(path.allowed),
machine_allowed: wheelPosition > 0,
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" &&
@@ -2002,13 +1976,13 @@ const savePathEditorPath = async () => {
},
answers,
result: {
machine_allowed: Boolean(pathEditorForm.value.machine_allowed),
machine_allowed: pathEditorMachineAllowed.value,
task: pathEditorForm.value.task || "Start machine",
description: pathEditorForm.value.description || "",
services: pathEditorForm.value.machine_allowed ? pathEditorServiceList.value : [],
buttons: pathEditorForm.value.machine_allowed ? pathEditorSelectedButtons.value : [],
services: pathEditorMachineAllowed.value ? pathEditorServiceList.value : [],
buttons: pathEditorMachineAllowed.value ? pathEditorSelectedButtons.value : [],
dynamic_images_vehicle_type: pathEditorProgramWheelPosition.value,
tasks: pathEditorForm.value.machine_allowed ? pathEditorTaskPayloads.value : [],
tasks: pathEditorMachineAllowed.value ? pathEditorTaskPayloads.value : [],
},
},
},
@@ -6134,25 +6108,6 @@ onBeforeUnmount(() => {
</div>
</header>
<div class="studio-machine-special-controls">
<label class="studio-switch-row">
<span>Press reset</span>
<input
v-model="pathEditorForm.press_reset"
type="checkbox"
data-testid="studio-path-editor-reset"
/>
</label>
<label class="studio-switch-row">
<span>Press start</span>
<input
v-model="pathEditorForm.press_start"
type="checkbox"
data-testid="studio-path-editor-start"
/>
</label>
</div>
<div class="studio-machine-button-grid" data-testid="studio-path-editor-buttons">
<button
v-for="option in pathEditorMachineButtonOptions"
@@ -6170,25 +6125,7 @@ onBeforeUnmount(() => {
</div>
<div class="studio-path-result-controls">
<label class="studio-switch-row studio-machine-allowed-row">
<span>Machine wash is allowed</span>
<input
v-model="pathEditorForm.machine_allowed"
type="checkbox"
data-testid="studio-path-editor-machine-allowed"
/>
</label>
<div v-if="pathEditorForm.machine_allowed" class="studio-path-result-control-grid">
<label
>Task text
<input
v-model="pathEditorForm.task"
class="input"
data-testid="studio-path-editor-task"
placeholder="Start machine"
/>
</label>
<div class="studio-path-result-control-grid">
<label
>Program wheel should be set to
<select
@@ -6205,7 +6142,17 @@ onBeforeUnmount(() => {
</option>
</select>
</label>
<label v-if="pathEditorMachineAllowed"
>Task text
<input
v-model="pathEditorForm.task"
class="input"
data-testid="studio-path-editor-task"
placeholder="Start machine"
/>
</label>
<label
v-if="pathEditorMachineAllowed"
>Description
<textarea
v-model="pathEditorForm.description"
@@ -6215,28 +6162,6 @@ onBeforeUnmount(() => {
></textarea>
</label>
</div>
<div v-if="pathEditorForm.machine_allowed" class="studio-field-group">
<span>Services</span>
<div class="studio-service-picker studio-path-service-picker">
<label
v-for="option in pathEditorServiceOptions"
:key="option.service"
class="studio-service-option"
:class="{
'is-active': pathEditorServiceList.includes(option.service),
'is-unbound': option.bindings.length === 0,
}"
>
<input v-model="pathEditorForm.services" type="checkbox" :value="option.service" />
<span>
<strong>{{ option.service }}</strong>
<small>{{ serviceOptionSummary(option) }}</small>
</span>
</label>
</div>
<small>{{ pathOutcomeServicesLabel(pathEditorServiceList) }}</small>
</div>
</div>
</section>
@@ -10494,7 +10419,6 @@ onBeforeUnmount(() => {
gap: 5px;
}
.studio-path-service-picker,
.studio-path-button-picker {
max-height: 220px;
overflow: auto;
@@ -10883,12 +10807,6 @@ onBeforeUnmount(() => {
gap: 14px;
}
.studio-machine-special-controls {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.studio-machine-button-grid {
display: grid;
gap: 10px;
@@ -13352,7 +13270,6 @@ onBeforeUnmount(() => {
flex-direction: column;
}
.studio-machine-special-controls,
.studio-machine-button-grid,
.studio-path-result-control-grid {
grid-template-columns: minmax(0, 1fr);
+21 -1
View File
@@ -1370,6 +1370,7 @@ function buildPathOutcomesResponse(request = {}, captured = {}) {
label: "Fold mirrors",
services: ["MACHINE"],
buttons: ["reset", 1, "start"],
dynamic_images_vehicle_type: 5,
order_priority: 1,
},
],
@@ -1474,6 +1475,7 @@ function buildPathOutcomesResponse(request = {}, captured = {}) {
label: "Fold mirrors",
services: ["MACHINE"],
buttons: ["reset", 1, "start"],
dynamic_images_vehicle_type: 5,
order_priority: 1,
},
],
@@ -3227,6 +3229,10 @@ test.describe("All-in-one self-serve studio", () => {
await expect(page.getByTestId("studio-path-verification-task-preview")).toContainText("Press reset");
await expect(page.getByTestId("studio-path-verification-task-preview")).toContainText("Press machine button 1");
await expect(page.getByTestId("studio-path-verification-task-preview")).toContainText("Press start");
await expect(page.getByTestId("studio-path-editor-reset")).toHaveCount(0);
await expect(page.getByTestId("studio-path-editor-start")).toHaveCount(0);
await expect(page.getByTestId("studio-path-editor-machine-allowed")).toHaveCount(0);
await expect(page.locator(".studio-path-service-picker")).toHaveCount(0);
await page.getByTestId("studio-path-preview-task-title-machine-1").click();
await page.getByTestId("studio-path-preview-task-title-input-machine-1").fill("Press machine button 1 gently");
await page.getByTestId("studio-path-preview-task-title-save-machine-1").click();
@@ -3243,7 +3249,6 @@ test.describe("All-in-one self-serve studio", () => {
await page.getByTestId("studio-path-editor-machine-button-1").click();
await expect(page.getByTestId("studio-path-editor-machine-button-1")).not.toHaveClass(/is-selected/);
await page.getByTestId("studio-path-editor-task").fill("Path editor start");
await page.getByTestId("studio-path-editor-reset").check();
await page.getByTestId("studio-path-editor-machine-button-2").click();
await page.getByTestId("studio-path-preview-task-title-machine-2").click();
await page.getByTestId("studio-path-preview-task-title-input-machine-2").fill("Press bay two button");
@@ -3286,6 +3291,21 @@ test.describe("All-in-one self-serve studio", () => {
)?.operations?.[0]?.data?.result?.buttons
)
.toEqual(["program_picker", "reset", 2, "start"]);
await expect
.poll(
() =>
captured.graphSaves.findLast(
(save) => save.operations?.[0]?.action === "upsert_path" && save.operations?.[0]?.entity === "path"
)?.operations?.[0]?.data?.result?.services
)
.toEqual(["MACHINE", "PROGRAM_PICKER"]);
await expect
.poll(() =>
captured.graphSaves
.findLast((save) => save.operations?.[0]?.action === "upsert_path" && save.operations?.[0]?.entity === "path")
?.operations?.[0]?.data?.result?.tasks?.map((task) => task.services)
)
.toEqual([["MACHINE", "PROGRAM_PICKER"], ["MACHINE"], ["MACHINE"], ["MACHINE"]]);
await page.getByTestId("studio-panel-flow").click();
await expect(page.locator('.vue-flow__node[data-id="condition:121"]')).toContainText("Path:");
@@ -153,6 +153,24 @@ describe("self-serve studio task editing", () => {
expect(source).toContain('data-testid="studio-path-hidden-non-machine-start-cases"');
});
it("derives path editor machine allowance from the program wheel", () => {
const source = studioSource();
expect(source).toContain(
"const pathEditorMachineAllowed = computed(() => Boolean(pathEditorProgramWheelPosition.value))"
);
expect(source).toContain('return ["MACHINE", "PROGRAM_PICKER"]');
expect(source).toContain("machine_allowed: pathEditorMachineAllowed.value");
expect(source).toContain("pathEditorMachineAllowed.value ? pathEditorTaskPayloads.value : []");
expect(source).toContain('data-testid="studio-path-editor-program"');
expect(source).not.toContain('data-testid="studio-path-editor-machine-allowed"');
expect(source).not.toContain('data-testid="studio-path-editor-reset"');
expect(source).not.toContain('data-testid="studio-path-editor-start"');
expect(source).not.toContain("const pathEditorServiceOptions = computed");
expect(source).not.toContain('class="studio-service-picker studio-path-service-picker"');
expect(source).not.toContain('v-model="pathEditorForm.services"');
});
it("supports task priority reordering from the task panel", () => {
const source = studioSource();