// Part 5 of 15 of the operator console app script. // // Queue list, import status, and the case review shell. // // 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. // Shown wherever a retained case's image would render. `assetMissing` is set by // the reload sweep when a decided case's source file has been moved out of the // queue folder: the decision is kept, the file is gone, and the operator needs // to be told which of the two happened. const MISSING_ASSET_NOTE = "원본 파일 없음 (판정 기록 보존)"; function renderQueue() { const rows = filteredSubmissions(); const queueLabel = state.submissionQueue?.label || ""; const queueMeta = queueLabel ? ` (${escapeHtml(queueLabel)})` : ""; const body = document.getElementById("queue-body"); document.getElementById("queue-health").innerHTML = state.apiError ? ` ${escapeHtml(state.apiError)} ` : ` ${rows.length}건 / 고위험 ${submissions.filter((item) => item.riskBand === "high").length}건${queueMeta} `; if (!rows.length) { body.innerHTML = `
현재 필터에 맞는 심사 건이 없습니다.
`; return; } body.innerHTML = rows .map((submission) => { const providerHtml = visibleProviderStateEntries(submission.providerState) .map( ([provider, status]) => { const fullLabel = `${providerLabels[provider] || provider} ${formatProviderStatus(status)}`; return `${escapeHtml(formatQueueProviderStatus(provider, status))}`; }, ) .join(""); const reasonHtml = submission.reasons .slice(0, 2) .map((reason) => `${escapeHtml(formatReason(reason))}`) .join(""); const submittedTime = formatQueueTimestamp(submission.submittedAt); return ` ${escapeHtml(submission.title)} 썸네일
${duplicateBadgeHtml(submission)}
${riskLabels[submission.riskBand]} ${submission.riskScore}
${reasonHtml}
${providerHtml}
${escapeHtml(submission.applicantStatus)} ${decisionLabels[submission.decisionStatus]}
${escapeHtml(submittedTime)}
`; }) .join(""); } function renderImportStatus() { const target = document.getElementById("submission-import-status"); if (!target) return; const tone = state.importStatus.tone === "bad" ? "bad" : state.importStatus.tone === "warn" ? "warn" : "ok"; target.innerHTML = ` ${escapeHtml(state.importStatus.message)} `; } function renderNoSelectedCase() { document.getElementById("case-title").textContent = "선택된 제출 이미지가 없습니다"; document.getElementById("case-image").removeAttribute("src"); document.getElementById("case-image").alt = ""; document.getElementById("image-derivative-note").textContent = "API 연결 후 제출 이미지를 불러오면 이 영역에 분석 대상이 표시됩니다."; document.getElementById("case-score").className = "score-pill risk-pending"; document.getElementById("case-score").textContent = "대기"; document.getElementById("floating-case-score").className = "score-pill risk-pending"; document.getElementById("floating-case-score").textContent = "대기"; document.getElementById("file-facts").innerHTML = ""; document.getElementById("similar-strip").innerHTML = ""; document.getElementById("face-crop-strip").innerHTML = ""; activeRerunAddedIds = new Set(); document.getElementById("rerun-diff-summary").innerHTML = ""; document.getElementById("case-reasons").innerHTML = `
표시할 케이스가 없습니다.
`; document.getElementById("evidence-groups").innerHTML = `
API 연결 실패 또는 제출 이미지 없음 상태입니다.
`; document.getElementById("recommendation-box").innerHTML = `실제 API 데이터가 로드될 때까지 판정하지 않습니다.`; document.getElementById("derived-preview").innerHTML = `자동 후보 생성 없음`; // Clear the evidence-gap recommendations too: they belong to the case that was // just deselected, and leaving them rendered invited running suggested queries // against nothing. const nextActions = document.getElementById("evidence-next-actions"); if (nextActions) nextActions.innerHTML = ""; const llmDraft = document.getElementById("llm-draft"); if (llmDraft) { llmDraft.hidden = true; llmDraft.innerHTML = ""; } } function renderCaseReview() { const submission = getSelectedCase(); if (!submission) { renderNoSelectedCase(); return; } renderLlmDraft(submission); document.getElementById("case-title").textContent = `${submission.id} · ${submission.title}`; const caseImageElement = document.getElementById("case-image"); caseImageElement.src = safeUrl(submission.asset); caseImageElement.alt = submission.assetMissing ? `${submission.id} 제출 이미지 - ${MISSING_ASSET_NOTE}` : `${submission.id} 제출 이미지`; caseImageElement.classList.toggle("case-image-missing", Boolean(submission.assetMissing)); // Reset the view controls (rotate / fit) so a new case starts unrotated. caseImageElement.dataset.rotation = "0"; caseImageElement.style.transform = ""; caseImageElement.style.objectFit = ""; // Without this the retained case is a broken with no explanation, and // the operator cannot tell "file archived after the decision" from "upload // corrupted". The decision survives in the DB either way; this is what makes // it readable in the console. document.getElementById("image-derivative-note").textContent = submission.assetMissing ? `${submission.derivativeNote} · ${MISSING_ASSET_NOTE}` : submission.derivativeNote; document.getElementById("case-score").className = `score-pill ${riskClass(submission.riskBand)}`; document.getElementById("case-score").textContent = `${riskLabels[submission.riskBand]} ${submission.riskScore}`; document.getElementById("floating-case-score").className = `score-pill ${riskClass(submission.riskBand)}`; document.getElementById("floating-case-score").textContent = `${riskLabels[submission.riskBand]} ${submission.riskScore}`; document.getElementById("file-facts").innerHTML = Object.entries(submission.fileFacts) .map(([label, value]) => `
${escapeHtml(formatFactLabel(label))}${escapeHtml(value)}
`) .join(""); document.getElementById("similar-strip").innerHTML = submission.similar .map( (item) => `
${escapeHtml(item.label)} ${escapeHtml(formatReason(item.label))}
`, ) .join(""); document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || []) .map( (crop) => ` 얼굴 영역 ${escapeHtml(String(crop.index))} 크롭 얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정은 아닙니다 `, ) .join(""); activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []); document.getElementById("rerun-diff-summary").innerHTML = submission.lastRerunDiff ? renderRerunDiffSummary(submission.lastRerunDiff) : ""; document.getElementById("case-reasons").innerHTML = renderEvidenceSummary(submission); document.getElementById("evidence-next-actions").innerHTML = renderEvidenceNextActions(submission); document.getElementById("evidence-groups").innerHTML = renderEvidenceGroups(submission); document.getElementById("recommendation-box").innerHTML = ` ${riskLabels[submission.riskBand]} ${escapeHtml(submission.recommendation.label)} ${escapeHtml(formatReason(submission.recommendation.detail))} 현재 운영 결정: ${decisionLabels[submission.decisionStatus]} `; document.getElementById("derived-preview").innerHTML = ` ${submission.derivedPreview.automatic ? "자동 후보 생성 예정" : "자동 이력 없음"} ${escapeHtml(submission.derivedPreview.entryName)} ${escapeHtml(submission.derivedPreview.effect)} `; }