// Part 8 of 15 of the operator console app script. // // View/tab switching, case selection, decisions, manual search, rerun. // // Load order is significant: these are classic (non-module) scripts sharing one // global scope, and index.html must keep them in the order listed above/below. // Splitting is a pure line-range extraction of the former single app.js. function switchView(view, updateHash = true) { if (view === "case" || view === "evidence") view = "workbench"; let knowledgeTabToApply = ""; if (view === "corrections") { view = "knowledge"; // Setting state alone left the visible panel on whatever knowledge tab was // last shown — the tab DOM is switched by switchKnowledgeTab, which must run // (applied at the end, after the view panels are toggled). knowledgeTabToApply = "corrections"; } if (!viewNames.has(view)) return; state.currentView = view; // Stats are read on demand (a fresh cross-tab each open) rather than shipped // in every bootstrap payload. if (view === "stats") { void loadDecisionStats(); } document.querySelectorAll(".nav-button").forEach((button) => { const active = button.dataset.view === view; button.classList.toggle("active", active); if (active) { button.setAttribute("aria-current", "page"); } else { button.removeAttribute("aria-current"); } }); document.querySelectorAll("[data-view-panel]").forEach((panel) => { const active = panel.dataset.viewPanel === view; panel.classList.toggle("active", active); panel.hidden = !active; }); if (knowledgeTabToApply) { switchKnowledgeTab(knowledgeTabToApply); } if (updateHash && window.location.hash.replace("#", "") !== view) { window.history.replaceState(null, "", `#${view}`); } } function switchKnowledgeTab(tab) { const nextTab = ["collect", "registered", "manual", "corrections"].includes(tab) ? tab : "collect"; state.knowledgeTab = nextTab; document.querySelectorAll("[data-knowledge-tab]").forEach((button) => { const active = button.dataset.knowledgeTab === nextTab; button.classList.toggle("active", active); button.setAttribute("aria-selected", active ? "true" : "false"); }); document.querySelectorAll("[data-knowledge-panel]").forEach((panel) => { const active = panel.dataset.knowledgePanel === nextTab; panel.classList.toggle("active", active); panel.hidden = !active; }); } function switchWorkbenchTab(tab) { const nextTab = ["evidence", "queries"].includes(tab) ? tab : "evidence"; state.workbenchTab = nextTab; document.querySelectorAll("[data-workbench-tab]").forEach((button) => { const active = button.dataset.workbenchTab === nextTab; button.classList.toggle("active", active); button.setAttribute("aria-selected", active ? "true" : "false"); }); document.querySelectorAll("[data-workbench-panel]").forEach((panel) => { const panelName = panel.dataset.workbenchPanel; const active = panelName === nextTab; panel.classList.toggle("active", active); panel.hidden = !active; }); } function selectCase(caseId) { state.selectedCaseId = caseId; suggestedQueryRunSummary = null; // Reset per-case inputs. The decision memo is submitted with whatever case is // selected at click time, so a memo typed for case A and left in the box was // recorded verbatim as the rejection/correction reason for case B. resetCaseScopedInputs(); switchView("workbench"); switchWorkbenchTab("evidence"); renderAll(); } function resetCaseScopedInputs() { const memo = document.getElementById("decision-memo"); if (memo) { memo.value = ""; } const memoError = document.getElementById("memo-error"); if (memoError) { memoError.textContent = ""; } const manualQuery = document.getElementById("manual-query"); if (manualQuery) { manualQuery.value = ""; } const manualStatus = document.getElementById("manual-query-status"); if (manualStatus) { manualStatus.textContent = ""; } } let isDecisionInFlight = false; async function setDecision(decision, requiresMemo = false) { // In-flight guard: a double-click, or clicking two different decisions // quickly, would otherwise POST two conflicting decisions concurrently. if (isDecisionInFlight) return; const submission = getSelectedCase(); const memo = document.getElementById("decision-memo").value.trim(); const error = document.getElementById("memo-error"); if (!submission) { error.textContent = "판정할 제출 이미지가 없습니다."; return; } if (requiresMemo && !memo) { error.textContent = "반려 또는 보정 결정에는 메모가 필요합니다."; return; } if (!requireOperatorName((message) => { error.textContent = message; })) return; error.textContent = ""; isDecisionInFlight = true; try { await apiJson(`/api/submissions/${encodeURIComponent(submission.id)}/decision`, { method: "POST", body: JSON.stringify({ decision, memo }), }); document.getElementById("decision-memo").value = ""; await refreshFromApi(); } catch (errorValue) { error.textContent = errorValue.message; } finally { isDecisionInFlight = false; } } let isManualSearchActive = false; async function runManualSearch(event) { event.preventDefault(); if (isManualSearchActive) return; const submission = getSelectedCase(); const provider = document.getElementById("manual-query-provider").value; const queryInput = document.getElementById("manual-query"); const status = document.getElementById("manual-query-status"); const query = queryInput.value.trim(); if (!submission) { status.textContent = "검색을 연결할 제출 이미지가 없습니다."; return; } if (!query) { status.textContent = "쿼리를 입력하세요."; return; } const providerConfig = providers.find((item) => item.id === provider); if (!providerConfig || !providerConfig.enabled) { status.textContent = `${providerLabels[provider] || provider} 외부 검색 tool이 비활성입니다.`; return; } isManualSearchActive = true; try { const targetCaseId = submission.id; await apiJson("/api/search/manual", { method: "POST", body: JSON.stringify({ submission_id: targetCaseId, provider, query }), }); // The operator may have moved to another case while the search ran. Naming // the case the evidence actually landed on prevents "added to the current // case" from pointing at the wrong one, and the input is only cleared when // it still belongs to the case we searched for. if (state.selectedCaseId === targetCaseId) { queryInput.value = ""; status.textContent = `${providerLabels[provider]} 쿼리 결과가 현재 케이스 증거에 추가되었습니다.`; } else { status.textContent = `${providerLabels[provider]} 쿼리 결과가 케이스 ${targetCaseId}에 추가되었습니다.`; } await refreshFromApi(); } catch (errorValue) { status.textContent = errorValue.message; } finally { isManualSearchActive = false; } } function normalizeManualSearchProvider(provider) { return window.OperatorSearch.normalizeManualSearchProvider(provider); } function rerunHistoricalQuery(button) { const queryInput = document.getElementById("manual-query"); const providerSelect = document.getElementById("manual-query-provider"); const provider = normalizeManualSearchProvider(button.dataset.rerunProvider); queryInput.value = button.dataset.rerunQuery || ""; providerSelect.value = provider; void runManualSearch(new Event("submit")); } async function rerunEnrichment() { const submission = getSelectedCase(); const button = document.getElementById("rerun-enrichment"); if (!submission) { showApiError("재분석할 제출 이미지가 없습니다."); return; } if (button) button.disabled = true; try { await apiJson(`/api/submissions/${encodeURIComponent(submission.id)}/rerun-enrichment`, { method: "POST", body: JSON.stringify({}), }); await refreshFromApi(); } catch (errorValue) { showApiError(errorValue.message); } finally { if (button) button.disabled = false; } } function selectedBulkSubmissionIds() { const ids = Array.from(document.querySelectorAll("input[data-bulk-id]:checked")).map((input) => input.dataset.bulkId); return ids.length ? ids : state.selectedCaseId ? [state.selectedCaseId] : []; } // 선례 승계. 별도 판정 경로를 만들지 않고 메모만 채워 setDecision 에 넘긴다 — // 반려 메모 필수 검사, 판정 시점 스냅샷, 감사 이벤트 같은 기존 보호장치를 그대로 // 타야 하기 때문이다. 승계했다는 사실은 메모로 감사에 남는다. async function inheritPriorDecision(decision) { const submission = getSelectedCase(); const summary = submission ? priorDecisionSummary(submission) : null; // 화면이 그 사이 바뀌었으면(다른 케이스 선택, 선례 판정 변경) 조용히 무시한다. if (!summary || summary.inheritable !== decision) return; const memoField = document.getElementById("decision-memo"); const peers = summary.priors.map((item) => item.submissionId).join(", "); if (memoField && !memoField.value.trim()) { memoField.value = `선례 승계: 동일 이미지 ${peers} 와 같은 판정`; } await setDecision(decision, decision === "rejected"); }