(() => { const STORAGE_KEY = "phenikaax_missions_v1"; const ACTION_GROUPS = { Move: [ { type: "move_to_position", label: "Go to position" }, { type: "move_to_marker", label: "Go to marker" }, { type: "adjust_localization", label: "Adjust localization" }, { type: "wait", label: "Wait" }, { type: "set_speed", label: "Set speed" }, ], Logic: [ { type: "if", label: "If" }, { type: "loop", label: "Loop", isLoop: true }, { type: "break", label: "Break" }, { type: "continue", label: "Continue" }, { type: "pause", label: "Pause" }, ], "I/O": [ { type: "set_digital_output", label: "Set digital output" }, { type: "wait_digital_input", label: "Wait for digital input" }, { type: "set_plc_register", label: "Set PLC register" }, ], Cart: [ { type: "pick_cart", label: "Pick cart" }, { type: "drop_cart", label: "Drop cart" }, ], Misc: [ { type: "user_log", label: "User log" }, { type: "play_sound", label: "Play sound" }, ], }; const DEFAULT_GROUPS = ["Missions", "Move", "Logic", "I/O", "Cart", "Misc"]; const SAMPLE_POSITIONS = ["Charging station", "Warehouse", "Production line 1", "Dock A"]; const SAMPLE_MARKERS = ["Marker 1", "Marker 2", "Home"]; const SAMPLE_IO_MODULES = ["GPIO module 1", "PLC I/O 1"]; const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"]; const el = (id) => document.getElementById(id); const missionListEl = el("missionList"); const missionListEmptyEl = el("missionListEmpty"); const missionsListViewEl = el("missionsListView"); const missionEditorViewEl = el("missionEditorView"); const missionActionBarEl = el("missionActionBar"); const missionGroupTabsEl = el("missionGroupTabs"); const missionActionListEl = el("missionActionList"); const missionEditorTitleEl = el("missionEditorTitle"); const missionEditorMetaEl = el("missionEditorMeta"); const missionEditorDirtyEl = el("missionEditorDirty"); const missionCreateDialogEl = el("missionCreateDialog"); const missionSettingsDialogEl = el("missionSettingsDialog"); const missionSaveAsDialogEl = el("missionSaveAsDialog"); const missionActionConfigDialogEl = el("missionActionConfigDialog"); const missionActionConfigBodyEl = el("missionActionConfigBody"); const missionActionConfigTitleEl = el("missionActionConfigTitle"); const missionQueueListEl = el("missionQueueList"); const missionQueueEmptyEl = el("missionQueueEmpty"); const missionQueueRunnerEl = el("missionQueueRunner"); const missionQueueDialogEl = el("missionQueueDialog"); const missionQueueVarFieldsEl = el("missionQueueVarFields"); const missionQueueDialogMissionEl = el("missionQueueDialogMission"); const missionQueueVarHintEl = el("missionQueueVarHint"); const VARIABLE_FIELD_DEFS = { move_to_position: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }], adjust_localization: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }], move_to_marker: [{ key: "marker", label: "Marker", options: SAMPLE_MARKERS }], if: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }], pick_cart: [ { key: "position", label: "Position", options: SAMPLE_POSITIONS }, { key: "cart", label: "Cart", options: SAMPLE_CARTS }, ], drop_cart: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }], }; const store = { missions: [], groups: [...DEFAULT_GROUPS], editingId: null, draft: null, dirty: false, drag: null, configActionId: null, configListPath: "root", queue: [], runner: { state: "idle", message: "" }, queuePollTimer: null, pendingQueueMissionId: null, }; function newId() { if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID(); return `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } function defaultParams(type) { switch (type) { case "move_to_position": return { position: SAMPLE_POSITIONS[0], check_free: true }; case "move_to_marker": return { marker: SAMPLE_MARKERS[0] }; case "adjust_localization": return { position: SAMPLE_POSITIONS[0] }; case "wait": return { seconds: 1 }; case "set_speed": return { speed: "normal" }; case "if": return { condition: "position_free", position: SAMPLE_POSITIONS[0] }; case "loop": return { count: 1, mode: "count" }; case "set_digital_output": return { module: SAMPLE_IO_MODULES[0], pin: 1, value: true }; case "wait_digital_input": return { module: SAMPLE_IO_MODULES[0], pin: 1, expected: true, timeout_s: 30 }; case "set_plc_register": return { register: 1, action: "set", value: 0 }; case "pick_cart": return { position: SAMPLE_POSITIONS[0], cart: SAMPLE_CARTS[0] }; case "drop_cart": return { position: SAMPLE_POSITIONS[0], collision_check: true }; case "user_log": return { message: "Mission step" }; case "play_sound": return { sound: "beep" }; default: return {}; } } function actionMeta(type) { for (const items of Object.values(ACTION_GROUPS)) { const hit = items.find((a) => a.type === type); if (hit) return hit; } return { type, label: type }; } function createAction(type, overrides = {}) { const meta = actionMeta(type); return { id: newId(), kind: "action", type, label: meta.label, params: defaultParams(type), children: meta.isLoop ? [] : undefined, ...overrides, }; } function createMissionRef(mission) { return { id: newId(), kind: "mission", refId: mission.id, label: mission.name, params: {}, }; } function createMission(name, group, description) { return { id: newId(), name: name.trim(), group: group.trim() || "Missions", description: (description || "").trim(), actions: [], updated_at: new Date().toISOString(), }; } function loadStoreLocal() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; const data = JSON.parse(raw); if (Array.isArray(data.missions)) store.missions = data.missions; if (Array.isArray(data.groups)) store.groups = data.groups; } catch { /* ignore */ } ensureDefaultGroups(); } async function loadStoreFromBackend() { try { const res = await fetch("/api/missions"); if (!res.ok) return false; const data = await res.json(); if (Array.isArray(data.missions)) store.missions = data.missions; if (Array.isArray(data.groups)) store.groups = data.groups; ensureDefaultGroups(); localStorage.setItem( STORAGE_KEY, JSON.stringify({ missions: store.missions, groups: store.groups }) ); return true; } catch { return false; } } let persistTimer = null; async function syncStoreToBackend() { try { await fetch("/api/missions", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ missions: store.missions, groups: store.groups }), }); } catch { /* ignore */ } } function loadStore() { loadStoreLocal(); } function persistStore() { localStorage.setItem( STORAGE_KEY, JSON.stringify({ missions: store.missions, groups: store.groups }) ); clearTimeout(persistTimer); persistTimer = setTimeout(syncStoreToBackend, 400); } function ensureDefaultGroups() { DEFAULT_GROUPS.forEach((g) => { if (!store.groups.includes(g)) store.groups.push(g); }); } function allGroups() { const fromMissions = store.missions.map((m) => m.group).filter(Boolean); return [...new Set([...store.groups, ...fromMissions])].sort((a, b) => a.localeCompare(b)); } function fillGroupSelect(selectEl, selected) { if (!selectEl) return; selectEl.innerHTML = ""; allGroups().forEach((g) => { const opt = document.createElement("option"); opt.value = g; opt.textContent = g; if (g === selected) opt.selected = true; selectEl.appendChild(opt); }); } function findMission(id) { return store.missions.find((m) => m.id === id) || null; } function getDraft() { return store.draft; } function setDirty(flag) { store.dirty = !!flag; if (missionEditorDirtyEl) missionEditorDirtyEl.hidden = !store.dirty; } function actionSummary(action) { if (action.kind === "mission") return `Mission con: ${action.label}`; const p = action.params || {}; const fmtVar = (key, val) => (p[`${key}_var`] ? `${val} (biến)` : val); switch (action.type) { case "move_to_position": return `Position: ${fmtVar("position", p.position)}${p.check_free ? " • kiểm tra trống" : ""}`; case "move_to_marker": return `Marker: ${fmtVar("marker", p.marker)}`; case "wait": return `${p.seconds}s`; case "set_speed": return `Speed: ${p.speed}`; case "loop": return p.mode === "endless" ? "Lặp vô hạn" : `Lặp ${p.count} lần • ${action.children?.length || 0} bước`; case "if": return `If ${p.condition} @ ${p.position || "—"}`; case "set_digital_output": return `${p.module} pin ${p.pin} → ${p.value ? "ON" : "OFF"}`; case "wait_digital_input": return `${p.module} pin ${p.pin} = ${p.expected ? "ON" : "OFF"}`; case "set_plc_register": return `Reg ${p.register}: ${p.action} ${p.value}`; case "pick_cart": case "drop_cart": return `${action.type === "pick_cart" ? "Pick" : "Drop"} @ ${fmtVar("position", p.position)}${action.type === "pick_cart" ? ` • ${fmtVar("cart", p.cart)}` : ""}`; case "user_log": return p.message || "—"; case "play_sound": return p.sound || "—"; default: return action.label; } } function findActionList(path) { const draft = getDraft(); if (!draft) return null; if (path === "root") return draft.actions; const parts = path.split("."); let list = draft.actions; for (const part of parts) { const node = list.find((a) => a.id === part); if (!node || !Array.isArray(node.children)) return null; list = node.children; } return list; } function findActionWithParent(actionId, list = getDraft()?.actions, path = "root", parent = null) { if (!list) return null; for (let i = 0; i < list.length; i += 1) { const action = list[i]; if (action.id === actionId) return { action, list, index: i, path, parent }; if (action.children?.length) { const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action); if (hit) return hit; } } return null; } function removeActionFromTree(actionId) { const hit = findActionWithParent(actionId); if (!hit) return false; hit.list.splice(hit.index, 1); setDirty(true); return true; } function moveAction(actionId, targetPath, targetIndex) { const hit = findActionWithParent(actionId); if (!hit) return false; const targetList = findActionList(targetPath); if (!targetList) return false; if (hit.path === targetPath) { const [item] = hit.list.splice(hit.index, 1); const idx = hit.index < targetIndex ? targetIndex - 1 : targetIndex; targetList.splice(idx, 0, item); setDirty(true); return true; } const movingIntoLoop = targetPath !== "root"; if (movingIntoLoop) { const loopId = targetPath.split(".").pop(); if (loopId === actionId) return false; let cursor = findActionWithParent(loopId); while (cursor) { if (cursor.action.id === actionId) return false; cursor = cursor.parent ? findActionWithParent(cursor.parent.id) : null; } } const [item] = hit.list.splice(hit.index, 1); targetList.splice(targetIndex, 0, item); setDirty(true); return true; } function addActionToList(type, listPath = "root") { const list = findActionList(listPath); if (!list) return; list.push(createAction(type)); setDirty(true); renderMissionEditor(); } function addMissionRefToList(missionId, listPath = "root") { const mission = findMission(missionId); const list = findActionList(listPath); if (!mission || !list || mission.id === store.editingId) return; list.push(createMissionRef(mission)); setDirty(true); renderMissionEditor(); } async function missionApi(path, opts = {}) { const res = await fetch(path, { headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, ...opts, }); if (!res.ok) { let msg = res.statusText; try { const err = await res.json(); if (err.error) msg = err.error; } catch { /* ignore */ } throw new Error(msg); } if (res.status === 204) return null; return res.json(); } function walkActions(actions, visit, depth = 0) { if (!Array.isArray(actions) || depth > 12) return; actions.forEach((action) => { visit(action, depth); if (Array.isArray(action.children)) walkActions(action.children, visit, depth + 1); }); } function resolveActionSnapshot(action, depth = 0) { const copy = JSON.parse(JSON.stringify(action)); if (copy.kind === "mission") { const ref = findMission(copy.refId); if (ref && depth < 8) copy.resolved_mission = resolveMissionSnapshot(ref, depth + 1); } if (Array.isArray(copy.children)) { copy.children = copy.children.map((child) => resolveActionSnapshot(child, depth)); } return copy; } function resolveMissionSnapshot(mission, depth = 0) { const copy = JSON.parse(JSON.stringify(mission)); copy.actions = (copy.actions || []).map((a) => resolveActionSnapshot(a, depth)); return copy; } function collectMissionVariables(mission) { const vars = []; walkActions(mission.actions, (action) => { if (action.kind === "mission" || !action.params) return; const defs = VARIABLE_FIELD_DEFS[action.type] || []; defs.forEach((def) => { if (!action.params[`${def.key}_var`]) return; vars.push({ key: `${action.id}:${def.key}`, label: `${action.label} — ${def.label}`, options: def.options, default: action.params[def.key] || def.options[0] || "", }); }); }); return vars; } function formatQueueParameters(entry) { const params = entry.parameters || {}; const keys = Object.keys(params); if (!keys.length) return ""; return keys .map((key) => `${escapeHtml(params[key])}`) .join(" • "); } function queueStatusLabel(status) { const map = { pending: "Chờ", executing: "Đang chạy", completed: "Xong", failed: "Lỗi", }; return map[status] || status; } async function refreshQueue() { try { const data = await missionApi("/api/mission_queue"); store.queue = Array.isArray(data.queue) ? data.queue : []; store.runner = data.runner && typeof data.runner === "object" ? data.runner : { state: "idle", message: "" }; renderQueuePanel(); notifyQueueUpdate(); } catch (e) { if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `Không tải được queue: ${e.message}`; } } const queueListeners = new Set(); function notifyQueueUpdate() { queueListeners.forEach((fn) => { try { fn(getQueueSnapshot()); } catch { /* ignore */ } }); } function getQueueSnapshot() { return { queue: JSON.parse(JSON.stringify(store.queue)), runner: JSON.parse(JSON.stringify(store.runner)), }; } function renderQueueInto(target, options = {}) { const listEl = target.listEl; const runnerEl = target.runnerEl; const emptyEl = target.emptyEl; const compact = !!options.compact; if (!listEl) return; listEl.innerHTML = ""; if (emptyEl) emptyEl.hidden = store.queue.length > 0; if (runnerEl) { const st = store.runner.state || "idle"; runnerEl.classList.toggle("running", st === "running" || st === "paused"); runnerEl.classList.toggle("paused", st === "paused"); const action = store.runner.current_action ? ` • ${store.runner.current_action}` : ""; runnerEl.textContent = store.runner.message ? `${store.runner.message}${action}` : st === "idle" ? compact ? "Sẵn sàng" : "Robot sẵn sàng — queue trống hoặc chờ mission mới." : "—"; } store.queue.forEach((entry, index) => { const row = document.createElement("div"); row.className = `missionQueueItem status-${entry.status || "pending"}${compact ? " compact" : ""}`; const paramHtml = formatQueueParameters(entry); const canReorder = entry.status === "pending" && !compact; row.innerHTML = compact ? `
${escapeHtml(entry.mission_name || "Mission")}
${queueStatusLabel(entry.status)} • #${index + 1}
${paramHtml ? `
${paramHtml}
` : ""}
${entry.status === "pending" ? `` : `${queueStatusLabel(entry.status)}`}
` : `
${escapeHtml(entry.mission_name || "Mission")}
${escapeHtml(entry.mission_group || "")} • #${index + 1}
${paramHtml ? `
${paramHtml}
` : ""}
${queueStatusLabel(entry.status)} ${entry.status === "pending" ? `` : ""}
`; row.querySelector("[data-queue-up]")?.addEventListener("click", () => moveQueueItem(entry.id, -1)); row.querySelector("[data-queue-down]")?.addEventListener("click", () => moveQueueItem(entry.id, 1)); row.querySelector("[data-queue-remove]")?.addEventListener("click", () => removeQueueItem(entry.id)); listEl.appendChild(row); }); } function renderQueuePanel() { renderQueueInto( { listEl: missionQueueListEl, runnerEl: missionQueueRunnerEl, emptyEl: missionQueueEmptyEl, }, { compact: false } ); } async function moveQueueItem(id, delta) { const ids = store.queue.map((q) => q.id); const idx = ids.indexOf(id); if (idx < 0) return; if (store.queue[idx].status !== "pending") return; const next = idx + delta; if (next < 0 || next >= store.queue.length) return; if (store.queue[next].status !== "pending") return; [ids[idx], ids[next]] = [ids[next], ids[idx]]; try { await missionApi("/api/mission_queue/reorder", { method: "PUT", body: JSON.stringify({ ordered_ids: ids }), }); await refreshQueue(); } catch (e) { alert(e.message); } } async function removeQueueItem(id) { try { await missionApi(`/api/mission_queue/${id}`, { method: "DELETE" }); await refreshQueue(); } catch (e) { alert(e.message); } } async function clearQueue() { if (!confirm("Xóa các mission đang chờ trong queue?")) return; try { await missionApi("/api/mission_queue", { method: "DELETE" }); await refreshQueue(); } catch (e) { alert(e.message); } } async function enqueueMission(missionId, parameters = {}) { const mission = findMission(missionId); if (!mission) throw new Error("Mission không tồn tại"); const payload = { mission: resolveMissionSnapshot(mission), parameters, }; await missionApi("/api/mission_queue", { method: "POST", body: JSON.stringify(payload) }); await refreshQueue(); } async function pauseRunner() { await missionApi("/api/mission_queue/pause", { method: "POST", body: "{}" }); await refreshQueue(); } async function continueRunner() { await missionApi("/api/mission_queue/continue", { method: "POST", body: "{}" }); await refreshQueue(); } function openQueueDialog(missionId) { const mission = findMission(missionId); if (!mission) return; store.pendingQueueMissionId = missionId; const vars = collectMissionVariables(mission); if (missionQueueDialogMissionEl) { missionQueueDialogMissionEl.textContent = `Mission: ${mission.name}`; } if (missionQueueVarFieldsEl) { missionQueueVarFieldsEl.innerHTML = ""; if (!vars.length) { missionQueueVarFieldsEl.innerHTML = `

Mission không có tham số biến. Bấm «Thêm vào queue» để chạy.

`; } else { vars.forEach((v) => { const row = document.createElement("div"); row.className = "row rowWide"; const lab = document.createElement("label"); lab.textContent = v.label; const sel = document.createElement("select"); sel.dataset.varKey = v.key; v.options.forEach((opt) => { const o = document.createElement("option"); o.value = opt; o.textContent = opt; if (opt === v.default) o.selected = true; sel.appendChild(o); }); row.appendChild(lab); row.appendChild(sel); missionQueueVarFieldsEl.appendChild(row); }); } } if (missionQueueVarHintEl) missionQueueVarHintEl.hidden = vars.length === 0; missionQueueDialogEl.showModal(); } async function submitQueueDialog(evt) { evt.preventDefault(); const mission = findMission(store.pendingQueueMissionId); if (!mission) return; const parameters = {}; missionQueueVarFieldsEl?.querySelectorAll("[data-var-key]").forEach((sel) => { parameters[sel.dataset.varKey] = sel.value; }); const payload = { mission: resolveMissionSnapshot(mission), parameters, }; try { await missionApi("/api/mission_queue", { method: "POST", body: JSON.stringify(payload) }); missionQueueDialogEl.close(); store.pendingQueueMissionId = null; await refreshQueue(); } catch (e) { alert(e.message); } } function startQueuePoll() { stopQueuePoll(); refreshQueue(); store.queuePollTimer = setInterval(refreshQueue, 1500); } function stopQueuePoll() { if (store.queuePollTimer) { clearInterval(store.queuePollTimer); store.queuePollTimer = null; } } function renderMissionList() { if (!missionListEl) return; missionListEl.innerHTML = ""; const items = [...store.missions].sort((a, b) => a.name.localeCompare(b.name)); missionListEmptyEl.hidden = items.length > 0; items.forEach((mission) => { const row = document.createElement("div"); row.className = "missionListItem"; row.innerHTML = `
${escapeHtml(mission.name)}
${escapeHtml(mission.group)} • ${mission.actions.length} action(s)${mission.description ? ` • ${escapeHtml(mission.description)}` : ""}
`; row.addEventListener("click", (evt) => { if (evt.target.closest("button")) return; openEditor(mission.id); }); row.querySelector("[data-queue]").addEventListener("click", (evt) => { evt.stopPropagation(); openQueueDialog(mission.id); }); row.querySelector("[data-edit]").addEventListener("click", (evt) => { evt.stopPropagation(); openEditor(mission.id); }); row.querySelector("[data-delete]").addEventListener("click", (evt) => { evt.stopPropagation(); if (!confirm(`Xóa mission «${mission.name}»?`)) return; store.missions = store.missions.filter((m) => m.id !== mission.id); persistStore(); renderMissionList(); }); missionListEl.appendChild(row); }); } function renderActionPalette() { if (!missionGroupTabsEl) return; missionGroupTabsEl.innerHTML = ""; Object.entries(ACTION_GROUPS).forEach(([groupName, actions]) => { const tab = document.createElement("div"); tab.className = "missionGroupTab"; const btn = document.createElement("button"); btn.type = "button"; btn.className = "missionGroupTabBtn"; btn.textContent = groupName; btn.dataset.group = groupName; const menu = document.createElement("div"); menu.className = "missionGroupMenu"; menu.hidden = true; actions.forEach((def) => { const item = document.createElement("button"); item.type = "button"; item.className = "missionPaletteItem"; item.innerHTML = `${def.isLoop ? "↻" : "▶"}${escapeHtml(def.label)}`; item.addEventListener("click", () => { addActionToList(def.type, "root"); menu.hidden = true; btn.classList.remove("open"); }); menu.appendChild(item); }); const embeddable = store.missions.filter((m) => m.id !== store.editingId); if (embeddable.length) { const sep = document.createElement("div"); sep.className = "mutedNote"; sep.style.padding = "6px 10px"; sep.textContent = "Missions có sẵn"; menu.appendChild(sep); embeddable.forEach((m) => { const item = document.createElement("button"); item.type = "button"; item.className = "missionPaletteItem missionRef"; item.innerHTML = `${escapeHtml(m.name)}`; item.addEventListener("click", () => { addMissionRefToList(m.id, "root"); menu.hidden = true; btn.classList.remove("open"); }); menu.appendChild(item); }); } btn.addEventListener("click", (evt) => { evt.stopPropagation(); const open = !menu.hidden; closeAllPaletteMenus(); menu.hidden = open; btn.classList.toggle("open", !open); }); tab.appendChild(btn); tab.appendChild(menu); missionGroupTabsEl.appendChild(tab); }); } function closeAllPaletteMenus() { document.querySelectorAll(".missionGroupMenu").forEach((m) => { m.hidden = true; }); document.querySelectorAll(".missionGroupTabBtn.open").forEach((b) => b.classList.remove("open")); } function renderActionRows(actions, listPath, container) { actions.forEach((action, index) => { const row = document.createElement("div"); row.className = "missionActionRow"; row.dataset.actionId = action.id; row.dataset.listPath = listPath; row.dataset.index = String(index); const iconClass = action.kind === "mission" ? "kind-mission" : action.type === "loop" ? "kind-loop" : ""; const iconChar = action.kind === "mission" ? "◎" : action.type === "loop" ? "↻" : "▶"; row.innerHTML = `
${iconChar} ${escapeHtml(action.label)}
${escapeHtml(actionSummary(action))}
`; if (action.type === "loop" && Array.isArray(action.children)) { const loop = document.createElement("div"); loop.className = "missionLoopBlock"; loop.innerHTML = `
Loop body — kéo action vào đây
`; const drop = document.createElement("div"); drop.className = "missionLoopDrop"; drop.dataset.loopPath = `${listPath}.${action.id}`; if (!action.children.length) { const empty = document.createElement("div"); empty.className = "missionLoopEmpty"; empty.textContent = "Kéo action hoặc mission vào loop"; drop.appendChild(empty); } else { renderActionRows(action.children, `${listPath}.${action.id}`, drop); } loop.appendChild(drop); row.querySelector(".missionActionMain").appendChild(loop); } row.querySelector("[data-config]").addEventListener("click", () => openActionConfig(action.id)); row.querySelector("[data-remove]").addEventListener("click", () => { removeActionFromTree(action.id); renderMissionEditor(); }); const handle = row.querySelector(".missionDragHandle"); handle.addEventListener("dragstart", (evt) => onDragStart(evt, action.id, listPath)); handle.addEventListener("dragend", onDragEnd); row.addEventListener("dragover", onRowDragOver); row.addEventListener("dragleave", onRowDragLeave); row.addEventListener("drop", onRowDrop); const loopDrop = row.querySelector(".missionLoopDrop"); if (loopDrop) { loopDrop.addEventListener("dragover", onLoopDragOver); loopDrop.addEventListener("dragleave", onLoopDragLeave); loopDrop.addEventListener("drop", onLoopDrop); } container.appendChild(row); }); } function renderMissionEditor() { const draft = getDraft(); if (!draft) return; missionEditorTitleEl.textContent = draft.name; missionEditorMetaEl.textContent = `${draft.group}${draft.description ? ` • ${draft.description}` : ""}`; renderActionPalette(); missionActionListEl.innerHTML = ""; renderActionRows(draft.actions, "root", missionActionListEl); bindDragPaletteItems(); } function bindDragPaletteItems() { document.querySelectorAll(".missionLoopDrop").forEach((drop) => { drop.addEventListener("dragover", onLoopDragOver); drop.addEventListener("dragleave", onLoopDragLeave); drop.addEventListener("drop", onLoopDrop); }); } function onDragStart(evt, actionId, listPath) { store.drag = { actionId, listPath, mode: "reorder" }; evt.dataTransfer.effectAllowed = "move"; evt.dataTransfer.setData("text/plain", actionId); evt.target.closest(".missionActionRow")?.classList.add("dragging"); } function onDragEnd(evt) { document.querySelectorAll(".missionActionRow.dragging, .missionActionRow.dropBefore, .missionActionRow.dropAfter").forEach((n) => { n.classList.remove("dragging", "dropBefore", "dropAfter"); }); document.querySelectorAll(".missionLoopDrop.dragOver").forEach((n) => n.classList.remove("dragOver")); store.drag = null; } function onRowDragOver(evt) { if (!store.drag || store.drag.mode !== "reorder") return; evt.preventDefault(); const row = evt.currentTarget; const rect = row.getBoundingClientRect(); const before = evt.clientY < rect.top + rect.height / 2; row.classList.toggle("dropBefore", before); row.classList.toggle("dropAfter", !before); } function onRowDragLeave(evt) { evt.currentTarget.classList.remove("dropBefore", "dropAfter"); } function onRowDrop(evt) { evt.preventDefault(); evt.stopPropagation(); if (!store.drag || store.drag.mode !== "reorder") return; const row = evt.currentTarget; const targetPath = row.dataset.listPath; let targetIndex = Number(row.dataset.index); if (row.classList.contains("dropAfter")) targetIndex += 1; row.classList.remove("dropBefore", "dropAfter"); moveAction(store.drag.actionId, targetPath, targetIndex); renderMissionEditor(); } function onLoopDragOver(evt) { evt.preventDefault(); evt.stopPropagation(); evt.currentTarget.classList.add("dragOver"); } function onLoopDragLeave(evt) { evt.currentTarget.classList.remove("dragOver"); } function onLoopDrop(evt) { evt.preventDefault(); evt.stopPropagation(); evt.currentTarget.classList.remove("dragOver"); if (!store.drag) return; const loopPath = evt.currentTarget.dataset.loopPath; const list = findActionList(loopPath); if (!list) return; if (store.drag.mode === "reorder") { moveAction(store.drag.actionId, loopPath, list.length); } else if (store.drag.mode === "palette") { if (store.drag.missionId) addMissionRefToList(store.drag.missionId, loopPath); else if (store.drag.actionType) { list.push(createAction(store.drag.actionType)); setDirty(true); } } renderMissionEditor(); } function openEditor(missionId) { const mission = findMission(missionId); if (!mission) return; store.editingId = missionId; store.draft = JSON.parse(JSON.stringify(mission)); setDirty(false); missionsListViewEl.hidden = true; missionEditorViewEl.hidden = false; renderMissionEditor(); } function closeEditor() { if (store.dirty && !confirm("Bỏ thay đổi chưa lưu?")) return; store.editingId = null; store.draft = null; setDirty(false); missionEditorViewEl.hidden = true; missionsListViewEl.hidden = false; renderMissionList(); } function saveDraft() { const draft = getDraft(); if (!draft) return false; if (!draft.name.trim()) { alert("Tên mission không được trống."); return false; } draft.updated_at = new Date().toISOString(); const idx = store.missions.findIndex((m) => m.id === draft.id); if (idx >= 0) store.missions[idx] = JSON.parse(JSON.stringify(draft)); else store.missions.push(JSON.parse(JSON.stringify(draft))); persistStore(); setDirty(false); renderMissionList(); return true; } function saveDraftAs(newName) { const draft = getDraft(); if (!draft) return false; const name = newName.trim(); if (!name) return false; if (store.missions.some((m) => m.name === name && m.id !== draft.id)) { alert("Tên mission đã tồn tại."); return false; } const copy = JSON.parse(JSON.stringify(draft)); copy.id = newId(); copy.name = name; copy.updated_at = new Date().toISOString(); store.missions.push(copy); persistStore(); store.editingId = copy.id; store.draft = copy; setDirty(false); renderMissionEditor(); renderMissionList(); return true; } function openCreateDialog() { fillGroupSelect(el("missionCreateGroup"), "Missions"); el("missionCreateName").value = ""; el("missionCreateGroupNew").value = ""; el("missionCreateDesc").value = ""; missionCreateDialogEl.showModal(); } function openSettingsDialog() { const draft = getDraft(); if (!draft) return; fillGroupSelect(el("missionSettingsGroup"), draft.group); el("missionSettingsName").value = draft.name; el("missionSettingsDesc").value = draft.description || ""; missionSettingsDialogEl.showModal(); } function openSaveAsDialog() { const draft = getDraft(); if (!draft) return; el("missionSaveAsName").value = `${draft.name} (copy)`; missionSaveAsDialogEl.showModal(); } function openActionConfig(actionId) { const hit = findActionWithParent(actionId); if (!hit || hit.action.kind === "mission") return; store.configActionId = actionId; missionActionConfigTitleEl.textContent = `Cấu hình: ${hit.action.label}`; missionActionConfigBodyEl.innerHTML = buildConfigForm(hit.action); missionActionConfigDialogEl.showModal(); } function buildConfigForm(action) { const p = action.params || {}; const grid = document.createElement("div"); grid.className = "missionConfigGrid"; const addField = (label, node) => { const row = document.createElement("div"); row.className = "row rowWide"; const lab = document.createElement("label"); lab.textContent = label; row.appendChild(lab); row.appendChild(node); grid.appendChild(row); }; const textInput = (key, value, type = "text") => { const input = document.createElement("input"); input.type = type; input.dataset.param = key; input.value = value ?? ""; return input; }; const selectInput = (key, value, options) => { const select = document.createElement("select"); select.dataset.param = key; options.forEach((opt) => { const o = document.createElement("option"); o.value = opt; o.textContent = opt; if (opt === value) o.selected = true; select.appendChild(o); }); return select; }; const addVariableToggle = (paramKey, fieldLabel) => { const chk = document.createElement("label"); chk.innerHTML = ` Biến — hỏi khi thêm vào queue`; addField(`${fieldLabel} (biến)`, chk); }; switch (action.type) { case "move_to_position": case "adjust_localization": addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS)); addVariableToggle("position", "Position"); if (action.type === "move_to_position") { const chk = document.createElement("label"); chk.innerHTML = ` Kiểm tra vị trí trống`; addField("Tuỳ chọn", chk); } break; case "move_to_marker": addField("Marker", selectInput("marker", p.marker, SAMPLE_MARKERS)); addVariableToggle("marker", "Marker"); break; case "wait": addField("Giây", textInput("seconds", p.seconds, "number")); break; case "set_speed": addField("Tốc độ", selectInput("speed", p.speed, ["slow", "normal", "fast"])); break; case "loop": addField("Chế độ", selectInput("mode", p.mode, ["count", "endless"])); addField("Số lần lặp", textInput("count", p.count, "number")); break; case "if": addField("Điều kiện", selectInput("condition", p.condition, ["position_free", "position_occupied", "register_equals"])); addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS)); addVariableToggle("position", "Position"); break; case "set_digital_output": addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES)); addField("Pin", textInput("pin", p.pin, "number")); { const chk = document.createElement("label"); chk.innerHTML = ` Bật (ON)`; addField("Trạng thái", chk); } break; case "wait_digital_input": addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES)); addField("Pin", textInput("pin", p.pin, "number")); addField("Timeout (s)", textInput("timeout_s", p.timeout_s, "number")); { const chk = document.createElement("label"); chk.innerHTML = ` Chờ mức ON`; addField("Kỳ vọng", chk); } break; case "set_plc_register": addField("Register", textInput("register", p.register, "number")); addField("Hành động", selectInput("action", p.action, ["set", "add", "subtract"])); addField("Giá trị", textInput("value", p.value, "number")); break; case "pick_cart": addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS)); addVariableToggle("position", "Position"); addField("Cart", selectInput("cart", p.cart, SAMPLE_CARTS)); addVariableToggle("cart", "Cart"); break; case "drop_cart": addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS)); addVariableToggle("position", "Position"); { const chk = document.createElement("label"); chk.innerHTML = ` Kiểm tra va chạm`; addField("An toàn", chk); } break; case "user_log": addField("Message", textInput("message", p.message)); break; case "play_sound": addField("Sound", selectInput("sound", p.sound, ["beep", "horn", "chime"])); break; default: grid.innerHTML = `

Action này không có tham số cấu hình.

`; } return grid.outerHTML; } function applyActionConfig() { const hit = findActionWithParent(store.configActionId); if (!hit) return; const params = { ...hit.action.params }; missionActionConfigBodyEl.querySelectorAll("[data-param]").forEach((node) => { const key = node.dataset.param; if (node.type === "checkbox") params[key] = node.checked; else if (node.type === "number") params[key] = Number(node.value); else params[key] = node.value; }); hit.action.params = params; setDirty(true); renderMissionEditor(); } function escapeHtml(str) { return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function bindEvents() { el("missionCreateOpenBtn")?.addEventListener("click", openCreateDialog); el("missionCreateForm")?.addEventListener("submit", (evt) => { evt.preventDefault(); const name = el("missionCreateName").value.trim(); if (!name) return; let group = el("missionCreateGroup").value; const groupNew = el("missionCreateGroupNew").value.trim(); if (groupNew) { group = groupNew; if (!store.groups.includes(group)) store.groups.push(group); } if (store.missions.some((m) => m.name === name)) { alert("Tên mission đã tồn tại."); return; } const mission = createMission(name, group, el("missionCreateDesc").value); store.missions.push(mission); persistStore(); missionCreateDialogEl.close(); renderMissionList(); openEditor(mission.id); }); el("missionEditorBackBtn")?.addEventListener("click", closeEditor); el("missionSaveBtn")?.addEventListener("click", () => { if (saveDraft()) alert("Đã lưu mission."); }); el("missionSaveAsBtn")?.addEventListener("click", openSaveAsDialog); el("missionSettingsBtn")?.addEventListener("click", openSettingsDialog); el("missionSettingsForm")?.addEventListener("submit", (evt) => { evt.preventDefault(); const draft = getDraft(); if (!draft) return; draft.name = el("missionSettingsName").value.trim(); draft.group = el("missionSettingsGroup").value; draft.description = el("missionSettingsDesc").value.trim(); if (!draft.name) { alert("Tên không được trống."); return; } setDirty(true); missionSettingsDialogEl.close(); renderMissionEditor(); }); el("missionSaveAsForm")?.addEventListener("submit", (evt) => { evt.preventDefault(); if (saveDraftAs(el("missionSaveAsName").value.trim())) { missionSaveAsDialogEl.close(); } }); el("missionActionConfigForm")?.addEventListener("submit", (evt) => { evt.preventDefault(); applyActionConfig(); missionActionConfigDialogEl.close(); }); document.querySelectorAll("[data-close-dialog]").forEach((btn) => { btn.addEventListener("click", () => { const id = btn.getAttribute("data-close-dialog"); el(id)?.close(); }); }); document.addEventListener("click", (evt) => { if (!evt.target.closest(".missionGroupTab")) closeAllPaletteMenus(); }); el("missionQueueClearBtn")?.addEventListener("click", clearQueue); el("missionQueueForm")?.addEventListener("submit", submitQueueDialog); } async function init() { loadStore(); await loadStoreFromBackend(); bindEvents(); renderMissionList(); } window.MissionsApp = { init, getMissions: () => [...store.missions], getGroups: () => allGroups(), getMissionById: findMission, queueMission: openQueueDialog, enqueueMission, pauseRunner, continueRunner, refreshQueue, clearQueue, getQueueSnapshot, renderQueueInto, onQueueUpdate(fn) { queueListeners.add(fn); return () => queueListeners.delete(fn); }, startQueuePoll, stopQueuePoll, onPageShow() { if (!missionEditorViewEl?.hidden) renderMissionEditor(); else { renderMissionList(); startQueuePoll(); } }, onPageHide() { stopQueuePoll(); }, }; init(); })();