승인·보류는 A/H 한 키에 자동 다음 케이스 이동까지 되는데 반려만 메모가
필수라 흐름이 끊겼다. R 은 메모 필드에 포커스만 줬고, 입력 중에는 전역
단축키가 막히므로(J/K 가 글자로 들어가면 안 되니 의도된 동작) 거기서부터는
마우스로만 제출할 수 있었다. 저작권 심사에서 법적으로 가장 중요한 판정이
가장 느린 경로였다.
R 사유 선택 모드 (프리셋에 1~9 번호가 보인다)
1~9 그 사유로 즉시 반려 + 다음 미심사 케이스로
M 직접 입력으로 전환
Esc 취소
메모 안에서 Ctrl+Enter 자유 서술 반려 제출
화면의 번호가 곧 눌러야 할 키다. 프리셋 칩에 키 배지를 붙여 단축키를 외우지
않아도 되게 했다.
숫자는 사유 선택 모드 안에서만 의미를 갖는다. 모드 밖에서 숫자에 의미를 주면
잘못 누른 키 하나로 반려가 나간다 — 되돌릴 수 있는 동작이지만 주의목록 항목을
만들고 감사에 남으므로 사고로 일어나서는 안 된다. 가드가 숫자 분기보다 먼저
오는지를 테스트가 위치로 확인한다.
프리셋이 하나도 없으면 고를 것이 없으므로 모드를 열지 않고 예전처럼 메모
필드로 보낸다.
실렌더(Playwright 1440x900, 실제 서버 + 부트스트랩 스텁):
R -> 2 {"decision":"rejected","memo":"라이선스 확인 불가로 반려"}
메모 + Ctrl+Enter {"decision":"rejected","memo":"자유 서술 사유"}
콘솔 에러 0.
검증: ruff clean, pytest 645 passed (정적 3건 추가).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
363 lines
7.2 KiB
JavaScript
363 lines
7.2 KiB
JavaScript
// Part 10 of 15 of the operator console app script.
|
||
//
|
||
// Memo presets, decision export, backup, cleared reference, LLM draft.
|
||
//
|
||
// 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.
|
||
|
||
// --- Decision memo presets ---------------------------------------------
|
||
|
||
|
||
|
||
function renderMemoPresets() {
|
||
|
||
const target = document.getElementById("memo-presets");
|
||
|
||
if (!target) return;
|
||
|
||
const chips = decisionMemoPresets
|
||
|
||
.map(
|
||
|
||
// 화면의 번호가 곧 단축키다. R 로 사유 선택에 들어간 뒤 같은 숫자를
|
||
// 누르면 그 사유로 반려된다. 10번째부터는 번호 없이 마우스로만 쓴다.
|
||
(preset, index) =>
|
||
|
||
`<button type="button" class="memo-preset-chip" data-preset-id="${escapeHtml(preset.id)}" title="${escapeHtml(preset.body)}">${index < 9 ? `<span class="memo-preset-key">${index + 1}</span>` : ""}${escapeHtml(preset.label)}</button>`,
|
||
|
||
)
|
||
|
||
.join("");
|
||
|
||
target.innerHTML = `${chips}<button type="button" class="memo-preset-chip memo-preset-add" id="memo-preset-save">+ 현재 메모 저장</button>`;
|
||
|
||
}
|
||
|
||
|
||
|
||
function applyMemoPreset(presetId) {
|
||
|
||
const preset = decisionMemoPresets.find((item) => item.id === presetId);
|
||
|
||
const memo = document.getElementById("decision-memo");
|
||
|
||
if (!preset || !memo) return;
|
||
|
||
memo.value = memo.value.trim() ? `${memo.value.trim()} ${preset.body}` : preset.body;
|
||
|
||
memo.focus();
|
||
|
||
}
|
||
|
||
|
||
|
||
async function saveCurrentMemoAsPreset() {
|
||
|
||
const memo = document.getElementById("decision-memo");
|
||
|
||
const error = document.getElementById("memo-error");
|
||
|
||
const body = memo?.value.trim() || "";
|
||
|
||
if (!body) {
|
||
|
||
if (error) error.textContent = "저장할 메모 내용이 없습니다.";
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
const label = window.prompt("프리셋 이름 (예: 연예인 초상)", body.slice(0, 20));
|
||
|
||
if (!label) return;
|
||
|
||
try {
|
||
|
||
const presets = await apiJson("/api/decision-memo-presets", {
|
||
|
||
method: "POST",
|
||
|
||
body: JSON.stringify({ label, body }),
|
||
|
||
});
|
||
|
||
replaceCollection(decisionMemoPresets, presets);
|
||
|
||
renderMemoPresets();
|
||
|
||
if (error) error.textContent = "";
|
||
|
||
} catch (errorValue) {
|
||
|
||
if (error) error.textContent = `프리셋 저장 실패: ${errorValue.message}`;
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
// --- Decision export ----------------------------------------------------
|
||
|
||
|
||
|
||
async function exportDecisions() {
|
||
|
||
const status = document.getElementById("bulk-decision-status");
|
||
|
||
const audience = window.confirm(
|
||
|
||
"내부 보관용(증거·점수 포함)으로 내보내려면 확인, 신청 채널 전달용(판정·메모만)으로 내보내려면 취소를 누르세요.",
|
||
|
||
)
|
||
|
||
? "internal"
|
||
|
||
: "applicant";
|
||
|
||
try {
|
||
|
||
const payload = await apiJson(
|
||
|
||
`/api/decisions/export?audience=${encodeURIComponent(audience)}`,
|
||
|
||
);
|
||
|
||
if (!payload.rows.length) {
|
||
|
||
if (status) status.textContent = "내보낼 판정 완료 케이스가 없습니다.";
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
downloadTextFile(
|
||
|
||
`copyrighter-decisions-${audience}.csv`,
|
||
|
||
payload.csv,
|
||
|
||
"text/csv;charset=utf-8",
|
||
|
||
);
|
||
|
||
if (status) status.textContent = `${payload.rows.length}건을 ${audience === "internal" ? "내부 보관용" : "신청 채널 전달용"} CSV로 내보냈습니다.`;
|
||
|
||
} catch (errorValue) {
|
||
|
||
if (status) status.textContent = `내보내기 실패: ${errorValue.message}`;
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
function downloadTextFile(filename, text, mimeType) {
|
||
|
||
// BOM so Excel opens the Korean CSV as UTF-8 instead of the system codepage.
|
||
|
||
const blob = new Blob([`${text}`], { type: mimeType });
|
||
|
||
const url = URL.createObjectURL(blob);
|
||
|
||
const link = document.createElement("a");
|
||
|
||
link.href = url;
|
||
|
||
link.download = filename;
|
||
|
||
document.body.appendChild(link);
|
||
|
||
link.click();
|
||
|
||
document.body.removeChild(link);
|
||
|
||
URL.revokeObjectURL(url);
|
||
|
||
}
|
||
|
||
|
||
|
||
// --- Backup -------------------------------------------------------------
|
||
|
||
|
||
|
||
async function createBackupSnapshot() {
|
||
|
||
const status = document.getElementById("bulk-decision-status");
|
||
|
||
const button = document.getElementById("create-backup");
|
||
|
||
if (button) button.disabled = true;
|
||
|
||
if (status) status.textContent = "백업 스냅샷을 만드는 중…";
|
||
|
||
try {
|
||
|
||
const result = await apiJson("/api/admin/backup", { method: "POST" });
|
||
|
||
const megabytes = (Number(result.sizeBytes || 0) / (1024 * 1024)).toFixed(1);
|
||
|
||
if (status) {
|
||
|
||
status.textContent = `백업 완료: ${result.file} (${megabytes}MB · 미디어 ${result.mediaFiles}개)`;
|
||
|
||
}
|
||
|
||
await refreshFromApi();
|
||
|
||
} catch (errorValue) {
|
||
|
||
if (status) status.textContent = `백업 실패: ${errorValue.message}`;
|
||
|
||
} finally {
|
||
|
||
if (button) button.disabled = false;
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
// --- Cleared reference (positive knowledge) -----------------------------
|
||
|
||
|
||
|
||
async function registerClearedReference() {
|
||
|
||
const submission = getSelectedCase();
|
||
|
||
if (!submission) return;
|
||
|
||
const label = window.prompt(
|
||
|
||
"클리어 기준 이름 (예: 자사 보유 스톡 · 라이선스 확인됨)",
|
||
|
||
submission.title || submission.id,
|
||
|
||
);
|
||
|
||
if (label === null) return;
|
||
|
||
const note = window.prompt("메모(선택) — 왜 클리어인지", "") || "";
|
||
|
||
try {
|
||
|
||
const payload = await apiJson(
|
||
|
||
`/api/submissions/${encodeURIComponent(submission.id)}/clear-reference`,
|
||
|
||
{ method: "POST", body: JSON.stringify({ label: label.trim(), note }) },
|
||
|
||
);
|
||
|
||
applyBootstrap(payload);
|
||
|
||
renderAll();
|
||
|
||
} catch (errorValue) {
|
||
|
||
showApiError(errorValue.message);
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
// --- LLM draft (persist + show + pull into memo) ------------------------
|
||
|
||
|
||
|
||
function llmSummaryFor(submission) {
|
||
|
||
const evidence = submission && Array.isArray(submission.evidence) ? submission.evidence : [];
|
||
|
||
// The freshest LLM summary that actually carries body text.
|
||
|
||
return (
|
||
|
||
evidence
|
||
|
||
.filter((item) => item.source === "llm" && String(item.summary || "").trim())
|
||
|
||
.slice(-1)[0] || null
|
||
|
||
);
|
||
|
||
}
|
||
|
||
|
||
|
||
let currentLlmDraftText = "";
|
||
|
||
|
||
|
||
function renderLlmDraft(submission) {
|
||
|
||
const box = document.getElementById("llm-draft");
|
||
|
||
if (!box) return;
|
||
|
||
const summary = llmSummaryFor(submission);
|
||
|
||
if (!summary) {
|
||
|
||
box.hidden = true;
|
||
|
||
box.innerHTML = "";
|
||
|
||
currentLlmDraftText = "";
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
currentLlmDraftText = String(summary.summary || "");
|
||
|
||
const urls = Array.isArray(summary.sourceUrls) ? summary.sourceUrls : [];
|
||
|
||
const sourceNote = urls.length ? `연결 근거 ${urls.length}건` : "연결 근거 없음";
|
||
|
||
box.hidden = false;
|
||
|
||
box.innerHTML = `
|
||
|
||
<div class="llm-draft-head">
|
||
|
||
<span class="llm-draft-tag">내부 요약(초안)</span>
|
||
|
||
<span class="muted small">${escapeHtml(sourceNote)} · 최종 판정은 운영자</span>
|
||
|
||
</div>
|
||
|
||
<p class="llm-draft-body">${escapeHtml(currentLlmDraftText)}</p>
|
||
|
||
<button type="button" class="llm-draft-use" id="llm-draft-use">메모 초안으로 넣기</button>
|
||
|
||
`;
|
||
|
||
}
|
||
|
||
|
||
|
||
function pullLlmDraftIntoMemo() {
|
||
|
||
const memo = document.getElementById("decision-memo");
|
||
|
||
if (!memo || !currentLlmDraftText) return;
|
||
|
||
memo.value = memo.value.trim()
|
||
|
||
? `${memo.value.trim()}\n${currentLlmDraftText}`
|
||
|
||
: currentLlmDraftText;
|
||
|
||
memo.focus();
|
||
|
||
}
|
||
|
||
|