From 9641f67e3aeeea77295bea5d3fd3037da4bf162e Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 29 Apr 2026 10:56:40 +0200 Subject: [PATCH] Extend Self-Serve Studio with "action" nodes, supporting creation, focus, and editing workflows. Add unit tests, normalize forms, and enhance virtual binding/gateway logic. Update E2E tests and node visualization settings. --- .../self-serve/DepartmentSelfServeStudio.vue | 971 +++++++++++++++++- .../displays/DepartmentDailyReportSmall.vue | 9 +- tests/e2e/admin-overview-night-washes.spec.ts | 3 + tests/e2e/self-serve-studio-flow.spec.js | 311 +++++- ...ment-overview-period-sync.behavior.spec.js | 50 + .../self-serve-studio-create-focus.spec.js | 24 + ...elf-serve-studio-managed-inspector.spec.js | 49 + 7 files changed, 1381 insertions(+), 36 deletions(-) create mode 100644 tests/unit/self-serve-studio-create-focus.spec.js create mode 100644 tests/unit/self-serve-studio-managed-inspector.spec.js diff --git a/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue b/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue index c33a5215..3a12b46b 100644 --- a/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue +++ b/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue @@ -103,6 +103,7 @@ const NODE_TEMPLATES = [ { kind: "question", label: "Question", icon: "fas fa-circle-question" }, { kind: "condition", label: "Condition", icon: "fas fa-code-branch" }, { kind: "task", label: "Task", icon: "fas fa-list-check" }, + { kind: "action", label: "Action", icon: "fas fa-bolt" }, ]; const CONDITION_OPERATORS = [ @@ -113,6 +114,33 @@ const CONDITION_OPERATORS = [ { value: "IS_FALSE_OR_NOT_SET", label: "is false or unanswered" }, ]; +const ACTION_EVENT_OPTIONS = [ + { value: "wash_start_command", label: "On self-serve wash start command" }, + { value: "wash_stop_command", label: "On self-serve wash stop command" }, + { value: "machine_start_triggered", label: "On self-serve wash machine start triggered" }, +]; + +const ACTION_WASH_MODE_OPTIONS = [ + { value: "both", label: "Manual / Machine / Both" }, + { value: "manual", label: "Manual only" }, + { value: "machine", label: "Machine only" }, +]; + +const ACTION_OPERATION_OPTIONS = [ + { value: "open_property_entrance_gate", label: "Open property entrance gate", relayRole: "PROPERTY_ENTRANCE" }, + { value: "open_property_exit_gate", label: "Open property exit gate", relayRole: "PROPERTY_EXIT" }, + { value: "open_lane_entrance_port", label: "Open lane entrance port", relayRole: "ENTRY" }, + { value: "open_lane_exit_port", label: "Open lane exit port", relayRole: "EXIT" }, + { value: "set_cleaner_relay", label: "Turn ON/OFF CLEANER", relayRole: "CLEANER", requiresState: true }, + { value: "set_machine_relay", label: "Turn ON/OFF MACHINE", relayRole: "MACHINE", requiresState: true }, + { value: "set_program_picker_relay", label: "Turn ON/OFF PROGRAM PICKER", relayRole: "PROGRAM_PICKER", requiresState: true }, +]; + +const ACTION_FAILURE_POLICY_OPTIONS = [ + { value: "continue", label: "Continue on failure" }, + { value: "block", label: "Block command on failure" }, +]; + const STUDIO_NODE_TYPES = { runtime_checkpoint: "studio-runtime", lane: "studio-scope", @@ -122,6 +150,7 @@ const STUDIO_NODE_TYPES = { condition: "studio-condition", rule: "studio-rule", task: "studio-task", + action: "studio-action", edge_gateway: "studio-gateway", relay_binding: "studio-relay-binding", relay: "studio-relay", @@ -137,6 +166,7 @@ const AUTO_LAYOUT_NODE_SIZES = { rule: { width: 276, height: 178 }, question: { width: 276, height: 178 }, task: { width: 276, height: 222 }, + action: { width: 276, height: 214 }, edge_gateway: { width: 276, height: 188 }, relay_binding: { width: 276, height: 190 }, relay: { width: 276, height: 152 }, @@ -147,6 +177,7 @@ const AUTO_LAYOUT_COLUMNS = [ { id: "conditions", kinds: ["condition"], top: 230 }, { id: "questions", kinds: ["question"], top: 230 }, { id: "tasks", kinds: ["task"], top: 230 }, + { id: "actions", kinds: ["action"], top: 230 }, { id: "hardware", kinds: ["edge_gateway", "relay_binding", "relay"], top: 230 }, ]; @@ -159,9 +190,10 @@ const AUTO_LAYOUT_KIND_ORDER = { rule: 50, question: 60, task: 70, - edge_gateway: 80, - relay_binding: 90, - relay: 100, + action: 80, + edge_gateway: 90, + relay_binding: 100, + relay: 110, }; const AUTO_LAYOUT_RUNTIME_STAGE_ORDER = { @@ -236,9 +268,12 @@ const graphStats = computed(() => { const permissions = computed(() => graphPayload.value?.permissions || {}); const selectedNode = computed(() => flowNodes.value.find((node) => node.id === selectedNodeId.value) || null); -const editableNodeKinds = ["question", "condition", "task", "lane"]; -const isSelectedEditable = computed(() => editableNodeKinds.includes(selectedNode.value?.data?.kind)); -const canDeleteSelected = computed(() => ["question", "condition", "task"].includes(selectedNode.value?.data?.kind)); +const selectedNodeKind = computed(() => selectedNode.value?.data?.kind || ""); +const editableNodeKinds = ["question", "condition", "task", "action", "lane"]; +const managedInspectorNodeKinds = ["runtime_checkpoint", "vehicle_type", "machine_type", "edge_gateway", "relay_binding", "relay"]; +const isSelectedEditable = computed(() => editableNodeKinds.includes(selectedNodeKind.value)); +const isSelectedManaged = computed(() => managedInspectorNodeKinds.includes(selectedNodeKind.value)); +const canDeleteSelected = computed(() => ["question", "condition", "task", "action"].includes(selectedNode.value?.data?.kind)); const simulatorDebug = computed(() => simulatorResult.value?.debug || null); const simulatorSummary = computed(() => simulatorDebug.value?.summary || null); const simulatorRecommendations = computed(() => Array.isArray(simulatorDebug.value?.recommendations) ? simulatorDebug.value.recommendations : []); @@ -682,6 +717,72 @@ const taskGateLabel = (data) => { return "Always"; }; +const actionOperationOption = (operation) => ( + ACTION_OPERATION_OPTIONS.find((option) => option.value === String(operation || "")) || ACTION_OPERATION_OPTIONS[0] +); + +const actionEventLabel = (event) => ( + ACTION_EVENT_OPTIONS.find((option) => option.value === String(event || ""))?.label || "On action event" +); + +const actionOperationLabel = (data) => { + const raw = rawFor(data); + const option = actionOperationOption(raw.operation); + if (!option.requiresState) { + return option.label; + } + return `${raw.relay_state === false ? "Turn OFF" : "Turn ON"} ${option.relayRole.replace("_", " ")}`; +}; + +const actionConditionLabel = (data) => { + const raw = rawFor(data); + return raw.condition_id ? labelFor("conditions", raw.condition_id) : "Always"; +}; + +const normalizeActionOptions = (options = {}) => ({ + delay_ms: Math.max(0, parseIntOrZero(options.delay_ms)), + toggle_after_seconds: parseNullableInt(options.toggle_after_seconds ?? 1), + retry_count: Math.min(3, Math.max(0, parseIntOrZero(options.retry_count))), + failure_policy: ACTION_FAILURE_POLICY_OPTIONS.some((option) => option.value === String(options.failure_policy)) + ? String(options.failure_policy) + : "continue", + record_event: options.record_event !== false, +}); + +const normalizeActionForm = (action = {}) => { + const operation = ACTION_OPERATION_OPTIONS.some((option) => option.value === action.operation) + ? action.operation + : "open_lane_entrance_port"; + const operationOption = actionOperationOption(operation); + return { + id: action.id ?? null, + name: String(action.name || action.label || operationOption.label).trim(), + description: String(action.description || ""), + event: ACTION_EVENT_OPTIONS.some((option) => option.value === action.event) ? action.event : "wash_start_command", + wash_mode: ACTION_WASH_MODE_OPTIONS.some((option) => option.value === action.wash_mode) ? action.wash_mode : "both", + operation, + relay_state: operationOption.requiresState ? action.relay_state !== false : null, + enabled: action.enabled !== false, + department: parseIntOrZero(action.department), + lane: parseIntOrZero(action.lane), + product: parseIntOrZero(action.product), + machine_type_id: parseNullableInt(action.machine_type_id), + condition_id: parseNullableInt(action.condition_id), + order_priority: parseIntOrZero(action.order_priority), + options: normalizeActionOptions(action.options || {}), + }; +}; + +const actionPayload = (action = {}) => { + const normalized = normalizeActionForm(action); + const operationOption = actionOperationOption(normalized.operation); + return { + ...normalized, + relay_state: operationOption.requiresState ? normalized.relay_state === true : null, + options: normalizeActionOptions(normalized.options), + }; +}; + const emptyConditionExpression = () => ({ type: "group", operator: "ALL", children: [] }); const normalizeConditionExpression = (expression) => { @@ -792,7 +893,7 @@ const conditionUsageCount = (conditionId) => { const nodeId = `condition:${conditionId}`; return flowEdges.value.filter((edge) => ( edge.source === nodeId - && ["visibility_gate", "task_gate", "condition_expression"].includes(edge?.data?.kind) + && ["visibility_gate", "task_gate", "action_gate", "condition_expression"].includes(edge?.data?.kind) )).length; }; @@ -919,7 +1020,7 @@ const autoLayoutSortValue = (node) => { if (kind === "runtime_checkpoint") { return AUTO_LAYOUT_RUNTIME_STAGE_ORDER[node?.data?.stage] ?? 99; } - if (kind === "task" || kind === "question") { + if (kind === "task" || kind === "question" || kind === "action") { return parseIntOrZero(raw.order_priority) || parseIntOrZero(raw.id) || parseIntOrZero(node?.data?.object_id); } if (kind === "rule") { @@ -1167,6 +1268,7 @@ const filteredListNodes = computed(() => flowNodes.value.filter(matchesNodeFilte const conditionNodes = computed(() => filteredListNodes.value.filter((node) => node.data?.kind === "condition")); const ruleNodes = computed(() => filteredListNodes.value.filter((node) => node.data?.kind === "rule")); const taskNodes = computed(() => filteredListNodes.value.filter((node) => node.data?.kind === "task")); +const actionNodes = computed(() => filteredListNodes.value.filter((node) => node.data?.kind === "action")); const gatewayNodes = computed(() => flowNodes.value.filter((node) => node.data?.kind === "edge_gateway")); const bindingNodes = computed(() => flowNodes.value.filter((node) => node.data?.kind === "relay_binding")); @@ -1399,6 +1501,215 @@ const validationItems = computed(() => { const selectedRaw = computed(() => selectedNode.value?.data?.raw || {}); +const truthyFlag = (value) => value === true || value === 1 || value === "1" || String(value || "").toLowerCase() === "true"; + +const nodeLabelById = (nodeId) => { + const node = flowNodes.value.find((entry) => entry.id === nodeId); + return node?.data?.label || nodeId; +}; + +const selectedNodeConnections = computed(() => { + if (!selectedNode.value) { + return []; + } + const currentId = selectedNode.value.id; + return flowEdges.value + .filter((edge) => edge.source === currentId || edge.target === currentId) + .map((edge) => { + const outgoing = edge.source === currentId; + const peerId = outgoing ? edge.target : edge.source; + return { + id: edge.id, + direction: outgoing ? "Out" : "In", + kind: edge?.data?.kind || edge.label || "link", + label: nodeLabelById(peerId), + nodeId: peerId, + }; + }); +}); + +const selectedGatewayId = computed(() => String( + selectedRaw.value?.id + ?? selectedRaw.value?.key + ?? selectedRaw.value?.gateway_key + ?? selectedRaw.value?.gateway_id + ?? selectedNode.value?.data?.object_id + ?? "" +).trim()); + +const selectedGatewayRecord = computed(() => { + const gatewayId = selectedGatewayId.value; + if (!gatewayId) { + return null; + } + return (gatewayWorkspace.value?.gateways || []).find((gateway) => ( + String(gateway.id ?? gateway.key ?? gateway.gateway_key ?? "") === gatewayId + )) || null; +}); + +const selectedGatewayBindingEntries = computed(() => { + const gatewayId = selectedGatewayId.value; + if (!gatewayId) { + return []; + } + return gatewayBindingEntries.value.filter((entry) => String(entry.gatewayId ?? "") === gatewayId); +}); + +const parseBindingNodeId = (nodeId) => { + const normalized = String(nodeId || ""); + if (!normalized.startsWith("binding:")) { + return {}; + } + const parts = normalized.split(":"); + const lastIndex = Math.max(2, parts.length - 1); + return { + gatewayId: parts[1] || "", + relayId: parts.slice(2, lastIndex).join(":") || parts[2] || "", + bindingIndex: parseIntOrZero(parts[lastIndex]), + }; +}; + +const selectedBindingCoordinates = computed(() => parseBindingNodeId(selectedNode.value?.id)); + +const selectedBindingEntry = computed(() => { + if (selectedNodeKind.value !== "relay_binding") { + return null; + } + const selectedId = selectedNode.value?.id; + const parsed = selectedBindingCoordinates.value; + const raw = selectedRaw.value; + return gatewayBindingEntries.value.find((entry) => entry.id === selectedId) + || gatewayBindingEntries.value.find((entry) => ( + String(entry.gatewayId ?? "") === String(raw.gateway_id ?? raw.gateway_key ?? parsed.gatewayId ?? "") + && String(entry.relayId ?? "") === String(raw.relay_id ?? selectedNode.value?.data?.object_id ?? parsed.relayId ?? "") + )) + || null; +}); + +const selectedRelayId = computed(() => String( + selectedRaw.value?.relay_id + ?? selectedRaw.value?.id + ?? selectedNode.value?.data?.object_id + ?? "" +).trim()); + +const selectedRelayBindingEntries = computed(() => { + const relayId = selectedRelayId.value; + if (!relayId) { + return []; + } + return gatewayBindingEntries.value.filter((entry) => String(entry.relayId) === relayId); +}); + +const normalizeVehicleTypeForm = (raw = {}) => { + const sourceRaw = raw?.raw ? rawFor(raw) : (raw || {}); + const productId = firstPositiveInt( + sourceRaw.product, + sourceRaw.product_id, + sourceRaw.id, + selectedNode.value?.data?.object_id + ); + const vehicleTypeId = firstPositiveInt(sourceRaw.id, selectedNode.value?.data?.object_id, productId); + const vehicleTypeRow = lookupRows("vehicle_types").find((vehicleType) => ( + vehicleTypeMatchesFilter(vehicleType, productId || vehicleTypeId) + )) || {}; + const productRow = lookupRowById("products", productId || vehicleTypeRow.product || vehicleTypeRow.product_id) || {}; + const merged = { + ...productRow, + ...vehicleTypeRow, + ...sourceRaw, + }; + return { + id: productId || vehicleTypeId, + vehicle_type_id: vehicleTypeId || productId, + name: String(merged.name || merged.label || selectedNode.value?.data?.label || "").trim(), + description: String(merged.description || ""), + price: Number(merged.price ?? 0), + category: merged.category ?? "", + piktogram: String(merged.piktogram || ""), + subscription_allowed: truthyFlag(merged.subscription_allowed), + is_wash: truthyFlag(merged.is_wash ?? 1), + order_priority: parseIntOrZero(merged.order_priority), + source: merged.source || "products", + }; +}; + +const normalizeMachineTypeForm = (raw = {}) => { + const sourceRaw = raw?.raw ? rawFor(raw) : (raw || {}); + const id = firstPositiveInt(sourceRaw.id, selectedNode.value?.data?.object_id); + const machineTypeRow = lookupRowById("machine_types", id) || {}; + const merged = { + ...machineTypeRow, + ...sourceRaw, + }; + return { + id, + name: String(merged.name || merged.label || selectedNode.value?.data?.label || "").trim(), + description: String(merged.description || ""), + }; +}; + +const normalizeGatewayForm = (raw = {}) => { + const sourceRaw = raw?.raw ? rawFor(raw) : (raw || {}); + const gateway = selectedGatewayRecord.value || {}; + const merged = { + ...gateway, + ...sourceRaw, + }; + const key = String(merged.key || merged.id || merged.gateway_key || selectedNode.value?.data?.object_id || "").trim(); + return { + key, + gateway_key: key, + label: String(merged.label || selectedNode.value?.data?.label || "Virtual Studio Gateway").trim(), + status: String(merged.status || selectedNode.value?.data?.subtitle || "VIRTUAL").trim(), + host: String(merged.host || merged.hostname || merged.ip || ""), + virtual: truthyFlag(merged.virtual), + }; +}; + +const normalizeBindingForm = (raw = {}) => { + const sourceRaw = raw?.raw ? rawFor(raw) : (raw || {}); + const parsed = selectedBindingCoordinates.value; + const entry = selectedBindingEntry.value || {}; + const binding = entry.binding || {}; + const merged = { + ...binding, + ...sourceRaw, + }; + const services = bindingServiceList(merged).length > 0 + ? bindingServiceList(merged) + : bindingServiceList(selectedNode.value?.data); + const role = normalizeServiceName(merged.role || merged.slot || services[0] || "MACHINE"); + return { + id: merged.id || binding.id || "", + gateway_key: String(merged.gateway_key || merged.gateway_id || entry.gatewayId || parsed.gatewayId || "virtual-main").trim(), + relay_id: String(merged.relay_id || entry.relayId || selectedNode.value?.data?.object_id || parsed.relayId || "").trim(), + role, + services: services.length > 0 ? services : [role], + label: String(merged.label || entry.label || selectedNode.value?.data?.label || "").trim(), + channel: parseNullableInt(merged.channel) ?? 0, + lane_id: parseNullableInt(merged.lane_id), + virtual: truthyFlag(merged.virtual || entry.virtual), + }; +}; + +const normalizeRelayForm = (raw = {}) => { + const sourceRaw = raw?.raw ? rawFor(raw) : (raw || {}); + const relayId = String(sourceRaw.relay_id || sourceRaw.id || selectedNode.value?.data?.object_id || "").trim(); + const relayRow = (gatewayWorkspace.value?.relays || []).find((relay) => String(relay.relay_id || relay.id || "") === relayId) || {}; + const merged = { + ...relayRow, + ...sourceRaw, + }; + return { + relay_id: relayId, + name: String(merged.name || merged.label || selectedNode.value?.data?.label || relayId).trim(), + status: String(merged.status || selectedNode.value?.data?.subtitle || "Unknown").trim(), + type: String(merged.type || ""), + virtual: truthyFlag(merged.virtual), + }; +}; + const normalizeFlowNodes = (nodes) => (Array.isArray(nodes) ? nodes : []).map((node) => ({ ...node, type: studioNodeType(node.data?.kind, node.type), @@ -1410,7 +1721,7 @@ const normalizeFlowNodes = (nodes) => (Array.isArray(nodes) ? nodes : []).map((n const normalizeFlowEdges = (edges) => (Array.isArray(edges) ? edges : []).map((edge) => ({ ...edge, - animated: ["runtime", "task_gate", "gateway_binding", "task_service"].includes(edge?.data?.kind), + animated: ["runtime", "task_gate", "gateway_binding", "task_service", "action_event"].includes(edge?.data?.kind), style: edgeStyle(edge?.data?.kind), })); @@ -1430,6 +1741,9 @@ const edgeStyle = (kind) => { if (kind === "task_service") { return { stroke: "#ea580c", strokeWidth: 2.5 }; } + if (kind === "action_event" || kind === "action_gate" || kind === "action_order") { + return { stroke: "#d97706", strokeWidth: 2.2, strokeDasharray: kind === "action_order" ? "6 4" : undefined }; + } if (kind === "scope") { return { stroke: "#94a3b8", strokeDasharray: "5 5" }; } @@ -1505,6 +1819,18 @@ const saveGraphOperations = async (operations, extraPayload = {}) => { } }; +const setSelectedNode = (nodeId, openInspector = true) => { + const normalizedNodeId = nodeId || null; + selectedNodeId.value = normalizedNodeId; + if (normalizedNodeId && openInspector) { + activePanel.value = "inspector"; + } + flowNodes.value = flowNodes.value.map((node) => { + const isSelected = node.id === normalizedNodeId; + return node.selected === isSelected ? node : { ...node, selected: isSelected }; + }); +}; + const saveLane = async () => { const payload = laneFormPayload(laneForm.value); if (!payload.name) { @@ -1563,6 +1889,35 @@ const scheduleLayoutSave = () => { layoutSaveTimer.value = window.setTimeout(saveLayout, 1200); }; +const findCreatedNode = (graph, kind, previousNodeIds) => { + const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []; + return nodes + .filter((node) => node?.data?.kind === kind && !previousNodeIds.has(node.id)) + .sort((left, right) => ( + (parseIntOrZero(rawFor(right.data).id || right.data?.object_id) - parseIntOrZero(rawFor(left.data).id || left.data?.object_id)) + || String(right.id || "").localeCompare(String(left.id || "")) + ))[0] || null; +}; + +const focusCreatedNode = async (nodeId) => { + if (!nodeId) { + return; + } + setSelectedNode(nodeId, true); + await nextTick(); + await waitForCanvasViewportSettled(); + setSelectedNode(nodeId, true); + await nextTick(); + fitView({ + nodes: [nodeId], + includeHiddenNodes: false, + minZoom: 0.7, + maxZoom: 1.08, + padding: 0.32, + duration: 260, + }).catch(() => {}); +}; + const createNode = async (kind) => { const defaults = { question: { @@ -1592,8 +1947,32 @@ const createNode = async (kind) => { services: [], buttons: [], }, + action: normalizeActionForm({ + name: "Open lane entrance port", + description: "", + event: "wash_start_command", + wash_mode: "both", + operation: "open_lane_entrance_port", + enabled: true, + lane: parseNullableInt(filters.value.lane_id) || 0, + product: parseNullableInt(filters.value.vehicle_type_id) || 0, + machine_type_id: parseNullableInt(filters.value.machine_type_id), + order_priority: (graphStats.value.action || 0) + 1, + options: { + delay_ms: 0, + toggle_after_seconds: 1, + retry_count: 0, + failure_policy: "continue", + record_event: true, + }, + }), }; - await saveGraphOperations([{ action: "create", entity: kind, data: defaults[kind] }]); + const previousNodeIds = new Set(flowNodes.value.map((node) => node.id)); + const graph = await saveGraphOperations([{ action: "create", entity: kind, data: defaults[kind] }]); + const createdNode = findCreatedNode(graph, kind, previousNodeIds); + if (createdNode) { + await focusCreatedNode(createdNode.id); + } }; const handlePaletteDragStart = (event, kind) => { @@ -1621,13 +2000,11 @@ const handleConnect = async (connection) => { }; const handleNodeClick = ({ node }) => { - selectedNodeId.value = node?.id || null; - activePanel.value = "inspector"; + setSelectedNode(node?.id || null, true); }; -const selectNode = (nodeId) => { - selectedNodeId.value = nodeId; - activePanel.value = "inspector"; +const selectNode = (nodeId, openInspector = true) => { + setSelectedNode(nodeId, openInspector); }; const clearCanvasFilters = () => { @@ -1652,6 +2029,14 @@ const waitForCanvasFilterReset = () => new Promise((resolve) => { window.setTimeout(resolve, 140); }); +const waitForCanvasViewportSettled = () => new Promise((resolve) => { + if (typeof window === "undefined") { + resolve(); + return; + } + window.setTimeout(resolve, hasActiveCanvasFilters.value ? 430 : 180); +}); + const focusDebugNodes = async (nodeIds = [], openInspector = false, options = {}) => { const ids = [...new Set((Array.isArray(nodeIds) ? nodeIds : []).filter(Boolean))]; if (ids.length === 0) { @@ -1703,6 +2088,24 @@ const refreshInspectorForm = () => { inspectorForm.value.services = normalizeServiceList(inspectorForm.value.services); inspectorForm.value.buttons = normalizeButtonList(inspectorForm.value.buttons); } + if (selectedNode.value?.data?.kind === "action") { + inspectorForm.value = normalizeActionForm(inspectorForm.value); + } + if (selectedNode.value?.data?.kind === "vehicle_type") { + inspectorForm.value = normalizeVehicleTypeForm(raw); + } + if (selectedNode.value?.data?.kind === "machine_type") { + inspectorForm.value = normalizeMachineTypeForm(raw); + } + if (selectedNode.value?.data?.kind === "edge_gateway") { + inspectorForm.value = normalizeGatewayForm(raw); + } + if (selectedNode.value?.data?.kind === "relay_binding") { + inspectorForm.value = normalizeBindingForm(raw); + } + if (selectedNode.value?.data?.kind === "relay") { + inspectorForm.value = normalizeRelayForm(raw); + } }; const saveInspector = async () => { @@ -1722,6 +2125,9 @@ const saveInspector = async () => { data.services = normalizeServiceList(data.services); data.buttons = normalizeButtonList(data.buttons); } + if (entity === "action") { + Object.assign(data, actionPayload(data)); + } await saveGraphOperations([{ action: "update", entity, @@ -1740,6 +2146,50 @@ const deleteSelected = async () => { selectedNodeId.value = null; }; +const saveVehicleTypeProduct = async () => { + const id = parseNullableInt(inspectorForm.value.id || selectedRaw.value.product || selectedRaw.value.product_id || selectedRaw.value.id); + if (!id) { + toast.error("Vehicle type product id is required."); + return; + } + try { + await requestPut("/products", { + id, + name: String(inspectorForm.value.name || "").trim(), + description: inspectorForm.value.description || "", + price: Number(inspectorForm.value.price || 0), + category: inspectorForm.value.category || null, + piktogram: inspectorForm.value.piktogram || "", + subscription_allowed: inspectorForm.value.subscription_allowed ? 1 : 0, + is_wash: inspectorForm.value.is_wash ? 1 : 0, + order_priority: parseIntOrZero(inspectorForm.value.order_priority), + }); + toast.success("Vehicle type updated."); + await loadStudioGraph(); + } catch (error) { + withErrorToast(error, "Vehicle type update failed."); + } +}; + +const saveMachineType = async () => { + const id = parseNullableInt(inspectorForm.value.id || selectedRaw.value.id || selectedNode.value?.data?.object_id); + if (!id) { + toast.error("Machine type id is required."); + return; + } + try { + await requestPut("/department/selfserve/machine-types", { + id, + name: String(inspectorForm.value.name || "").trim(), + description: inspectorForm.value.description || null, + }); + toast.success("Machine type updated."); + await loadStudioGraph(); + } catch (error) { + withErrorToast(error, "Machine type update failed."); + } +}; + const validateGraph = async () => { try { const payload = await requestPost("/department/selfserve/studio/validate", { department: departmentId.value }); @@ -1815,6 +2265,19 @@ const upsertVirtualGateway = () => applyVirtualHardwareOperation("upsert_gateway status: "VIRTUAL", }); +const saveSelectedVirtualGateway = () => { + const key = String(inspectorForm.value.gateway_key || inspectorForm.value.key || selectedGatewayId.value || "virtual-main").trim(); + if (!key) { + toast.error("Gateway key is required."); + return null; + } + return applyVirtualHardwareOperation("upsert_gateway", { + key, + label: inspectorForm.value.label || "Virtual Studio Gateway", + status: inspectorForm.value.status || "VIRTUAL", + }); +}; + const upsertVirtualBinding = () => { const relayId = String(virtualHardwareForm.value.relay_id || "").trim(); if (!relayId) { @@ -1830,6 +2293,32 @@ const upsertVirtualBinding = () => { }); }; +const saveSelectedVirtualBinding = () => { + const relayId = String(inspectorForm.value.relay_id || "").trim(); + if (!relayId) { + toast.error("Relay ID is required for a virtual binding."); + return null; + } + const services = normalizeServiceList(inspectorForm.value.services); + const role = normalizeServiceName(inspectorForm.value.role || services[0] || "MACHINE"); + const payload = { + gateway_key: inspectorForm.value.gateway_key || "virtual-main", + relay_id: relayId, + role, + services: services.length > 0 ? services : [role], + label: inspectorForm.value.label || `${role} ${relayId}`, + channel: parseNullableInt(inspectorForm.value.channel) ?? 0, + }; + const laneId = parseNullableInt(inspectorForm.value.lane_id); + if (laneId) { + payload.lane_id = laneId; + } + if (inspectorForm.value.id) { + payload.id = inspectorForm.value.id; + } + return applyVirtualHardwareOperation("upsert_binding", payload); +}; + const deleteVirtualBinding = (entry) => applyVirtualHardwareOperation("delete_binding", { id: entry?.binding?.id, gateway_key: entry?.binding?.gateway_key || entry?.gatewayId || "virtual-main", @@ -1837,6 +2326,16 @@ const deleteVirtualBinding = (entry) => applyVirtualHardwareOperation("delete_bi role: entry?.binding?.role, }); +const deleteSelectedVirtualBinding = () => deleteVirtualBinding({ + binding: { + id: inspectorForm.value.id, + gateway_key: inspectorForm.value.gateway_key, + role: inspectorForm.value.role, + }, + gatewayId: inspectorForm.value.gateway_key, + relayId: inspectorForm.value.relay_id, +}); + const resetVirtualHardware = () => applyVirtualHardwareOperation("reset"); const useVirtualRelaySlot = (slotId) => { @@ -2124,6 +2623,7 @@ const nodeIcon = (kind) => ({ condition: "fas fa-code-branch", rule: "fas fa-gavel", task: "fas fa-list-check", + action: "fas fa-bolt", lane: "fas fa-road", machine_type: "fas fa-gears", vehicle_type: "fas fa-truck", @@ -2138,6 +2638,7 @@ const nodeTone = (kind) => ({ condition: "is-condition", rule: "is-rule", task: "is-task", + action: "is-action", edge_gateway: "is-gateway", relay_binding: "is-hardware", relay: "is-hardware", @@ -2343,7 +2844,7 @@ onBeforeUnmount(() => { :elements-selectable="true" @connect="handleConnect" @node-click="handleNodeClick" - @pane-click="selectedNodeId = null" + @pane-click="selectNode(null, false)" > + + + +
-
+
+
+

Actions

+ +
+
+
+ + {{ node.data?.raw?.wash_mode || 'both' }} +
+

No configured actions.

+