// Part 9 of 15 of the operator console app script. // // Keyboard review mode, duplicate grouping, and batch decisions. // // 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. // --- Keyboard review mode ------------------------------------------------ const KEYBOARD_HELP = [ "J / ↓ : 다음 케이스", "K / ↑ : 이전 케이스", "A : 승인 H : 보류 R : 반려(메모로 이동)", "N : 다음 미심사 케이스", "? : 이 도움말 열기/닫기", "Esc : 도움말 닫기", ].join("\n"); function isTypingTarget(target) { if (!target) return false; const tag = String(target.tagName || "").toLowerCase(); return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable; } function moveSelection(offset) { const rows = filteredSubmissions(); if (!rows.length) return; const index = rows.findIndex((item) => item.id === state.selectedCaseId); // From "no selection", J lands on the first row and K on the last. const nextIndex = index === -1 ? (offset > 0 ? 0 : rows.length - 1) : Math.min(rows.length - 1, Math.max(0, index + offset)); selectCase(rows[nextIndex].id); } function moveToNextUnreviewed() { const rows = filteredSubmissions(); const index = rows.findIndex((item) => item.id === state.selectedCaseId); const ordered = [...rows.slice(index + 1), ...rows.slice(0, Math.max(0, index + 1))]; const next = ordered.find((item) => item.decisionStatus === "unreviewed"); if (next) { selectCase(next.id); } else { setImportStatus("미심사 케이스가 없습니다.", "ok"); } } function renderReviewProgress() { const target = document.getElementById("review-progress"); if (!target) return; const rows = filteredSubmissions(); const position = rows.findIndex((item) => item.id === state.selectedCaseId) + 1; const remaining = rows.filter((item) => item.decisionStatus === "unreviewed").length; target.textContent = rows.length ? `${position || "-"}/${rows.length} · 남은 미심사 ${remaining}건 · ? 도움말` : ""; } function handleReviewShortcut(event) { if (event.ctrlKey || event.metaKey || event.altKey) return; if (isTypingTarget(event.target)) return; const key = event.key; if (key === "Escape") { const help = document.getElementById("keyboard-help"); if (help && !help.hidden) help.hidden = true; return; } if (key === "?") { const help = document.getElementById("keyboard-help"); if (help) { help.textContent = KEYBOARD_HELP; help.hidden = !help.hidden; } event.preventDefault(); return; } // Navigation works from the queue and the workbench; decisions only make // sense once a case is open. if (key === "j" || key === "J" || key === "ArrowDown") { moveSelection(1); event.preventDefault(); return; } if (key === "k" || key === "K" || key === "ArrowUp") { moveSelection(-1); event.preventDefault(); return; } if (key === "n" || key === "N") { moveToNextUnreviewed(); event.preventDefault(); return; } if (!state.selectedCaseId) return; if (key === "a" || key === "A") { setDecision("approved").then(() => moveToNextUnreviewed()); event.preventDefault(); return; } if (key === "h" || key === "H") { setDecision("held").then(() => moveToNextUnreviewed()); event.preventDefault(); return; } if (key === "r" || key === "R") { // Rejection needs a memo, so hand the operator the field instead of firing // a request that would fail validation. switchView("workbench"); const memo = document.getElementById("decision-memo"); if (memo) memo.focus(); event.preventDefault(); } } // --- Duplicate / prior-case grouping ------------------------------------ function duplicateBadgeHtml(submission) { const group = submission.duplicateGroup; if (!group || !group.size) return ""; const priors = group.priorDecisions || []; const priorText = priors.length ? `선례: ${priors .map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`) .join(", ")}` : "아직 판정된 선례 없음"; const tone = group.conflicting ? " conflicting" : ""; const conflictNote = group.conflicting ? " · 판정 불일치" : ""; // 선례를 배지 본문에 싣는다. title 에만 두면 마우스를 올린 사람만 알 수 있고, // 큐를 훑는 동안에는 "이미 판정한 이미지"가 보이지 않는다. const summary = priorDecisionSummary(submission); const priorNote = group.conflicting ? conflictNote : summary && summary.inheritable ? ` · 선례 ${decisionLabels[summary.inheritable] || summary.inheritable}` : ""; return ``; } function selectDuplicateGroup(submissionId) { // Tick the whole group so the operator can hand it to batch re-analysis or a // batch decision in one move. const submission = submissions.find((item) => item.id === submissionId); const group = submission && submission.duplicateGroup; if (!group) return; const ids = new Set([submissionId, ...(group.peerIds || [])]); document.querySelectorAll("input[data-bulk-id]").forEach((input) => { input.checked = ids.has(input.dataset.bulkId); }); renderBulkDecisionCount(); const status = document.getElementById("bulk-decision-status"); if (status) { const priors = group.priorDecisions || []; status.textContent = priors.length ? `동일 이미지 ${ids.size}건 선택 · 선례 ${priors .map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`) .join(", ")}` : `동일 이미지 ${ids.size}건을 선택했습니다.`; } } // --- Batch decisions --------------------------------------------------- function checkedBulkSubmissionIds() { // Unlike selectedBulkSubmissionIds(), this never falls back to the current // case: a batch decision must act on an explicit selection only. return Array.from(document.querySelectorAll("input[data-bulk-id]:checked")).map( (input) => input.dataset.bulkId, ); } function renderBulkDecisionCount() { const target = document.getElementById("bulk-decision-count"); if (!target) return; const ids = checkedBulkSubmissionIds(); const highRisk = ids.filter((id) => { const submission = submissions.find((item) => item.id === id); return submission && submission.riskBand === "high"; }).length; target.textContent = highRisk ? `선택 ${ids.length}건 (고위험 ${highRisk}건)` : `선택 ${ids.length}건`; } let isBulkDecisionInFlight = false; async function applyBulkDecision(decision) { if (isBulkDecisionInFlight) return; const ids = checkedBulkSubmissionIds(); const status = document.getElementById("bulk-decision-status"); const setStatus = (message) => { if (status) status.textContent = message; }; if (!ids.length) { setStatus("판정할 케이스를 먼저 선택하세요."); return; } const memo = document.getElementById("decision-memo")?.value.trim() || ""; if (decision === "rejected" && !memo) { setStatus("일괄 반려에는 결정 메모가 필요합니다. 메모를 입력한 뒤 다시 시도하세요."); return; } const highRisk = ids.filter((id) => { const submission = submissions.find((item) => item.id === id); return submission && submission.riskBand === "high"; }); // Guardrail: approving a high-risk case in bulk is the one irreversible-feeling // action here, so name the count instead of asking a generic "are you sure". if (highRisk.length) { const label = decisionLabels[decision] || decision; if (!window.confirm(`선택 ${ids.length}건 중 고위험 ${highRisk.length}건이 포함되어 있습니다. 그대로 ${label} 처리할까요?`)) { setStatus("일괄 판정을 취소했습니다."); return; } } isBulkDecisionInFlight = true; setStatus(`${ids.length}건 판정 중…`); try { const payload = await apiJson("/api/submissions/decision-batch", { method: "POST", body: JSON.stringify({ submission_ids: ids, decision, memo }), }); const result = payload.batchDecision || { applied: [], failed: [] }; applyBootstrap(payload); renderAll(); const failures = result.failed || []; setStatus( failures.length ? `${(result.applied || []).length}건 적용 · ${failures.length}건 실패: ${failures .map((item) => `${item.submissionId}(${item.error})`) .join(", ")}` : `${(result.applied || []).length}건 판정을 적용했습니다.`, ); } catch (errorValue) { setStatus(`일괄 판정 실패: ${errorValue.message}`); } finally { isBulkDecisionInFlight = false; } }