let returnDocuments = []; let editingReturnDocument = null; const $return = id => document.getElementById(id); const returnReviewYear = Number(new Intl.DateTimeFormat('en-US', {timeZone:'Asia/Seoul',year:'numeric'}).format(new Date())); function returnDocumentYears(doc, byId) { const visited = new Set(); while (doc && !visited.has(doc.id)) { visited.add(doc.id); const years = [...new Set([...(doc.events || []), ...(doc.policies || [])] .map(item => item.year).filter(year => Number.isInteger(year) && year >= 2000 && year <= 2100))]; if (years.length) return years.sort((a,b) => a-b); // A withdrawal or unstructured correction can inherit its original's period. doc = byId.get(doc.supersedes_id); } return []; } function matchesReturnPeriod(years, period, currentYear = returnReviewYear) { if (period === 'all') return true; if (period === 'unknown') return years.length === 0; if (period === 'history') return years.some(year => year < currentYear); return years.some(year => year === currentYear || year === currentYear+1); } async function returnAdminRequest(path, options = {}) { const response = await fetch('/api/admin/returns/' + path, options); const data = await response.json(); if (!response.ok) { if (response.status === 401 || response.status === 403) throw new Error('관리자 로그인이 필요합니다. 스크리너에서 로그인하세요.'); const message = typeof data.detail === 'string' ? data.detail : Array.isArray(data.detail) ? data.detail.map(item => String(item.msg).replace(/^Value error, /,'')).join(' · ') : '요청을 처리하지 못했습니다.'; throw new Error(message); } return data; } function adminMessage(message) { $return('admin-message').textContent = message; } function field(label, name, value, type = 'text', choices = null) { const wrap = document.createElement('label'); wrap.textContent = label; const input = document.createElement(choices ? 'select' : type === 'textarea' ? 'textarea' : 'input'); input.name = name; if (choices) { for (const [key, text] of choices) input.add(new Option(text, key)); input.value = String(value ?? choices[0][0]); } else if (type === 'checkbox') { input.type = type; input.checked = !!value; } else { if (type !== 'textarea') input.type = type; input.value = value ?? ''; } if (type === 'number') input.step = 'any'; wrap.append(input); return wrap; } const returnScopes = [['unknown','범위 미확인'],['common','보통주'],['company','회사 전체'],['preferred','우선주']]; function addReturnItem(kind, value = {}) { const block = document.createElement('fieldset'); block.className = 'return-item return-fields'; block.append(field('적용 연도','year', value.year ?? returnReviewYear, 'number')); block.append(field('주식 범위','scope', value.scope, 'text', returnScopes)); if (kind === 'event') { block.append(field('소각예정금액(억원)','amount', value.amount,'number')); block.append(field('소각예정일','cancellation_date',value.cancellation_date,'date')); block.append(field('연간 목표 외 추가 소각 확인','outside_policy',value.outside_policy,'checkbox')); } else { block.append(field('정책 종류','kind',value.kind,'text', [['cancellation','연간 소각 금액'],['total_amount','총환원 금액'],['total_ratio','총환원율'],['none','소각 계획 없음 확인']])); block.append(field('목표 금액(억원)','amount',value.amount,'number')); block.append(field('목표 환원율(0.5 = 50%)','ratio',value.ratio,'number')); block.append(field('회사의 소각 방침 확인','cancellation_commitment',value.cancellation_commitment,'checkbox')); block.append(field('목표 이익 기준','income_basis',value.income_basis ?? 'unknown','text', [['unknown','직접 확인 필요'],['net_income','수집된 당기순이익'],['owner_net_income','지배주주 순이익'],['adjusted','조정 이익 · 직접 보완']])); block.append(field('동일 기준 예상 이익 보완(억원)','income_amount',value.income_amount,'number')); block.append(field('동일 범위 예상 배당총액 보완(억원)','dividend_amount',value.dividend_amount,'number')); } block.append(field('금액·범위·가정의 근거','evidence',value.evidence,'textarea')); const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = '항목 삭제'; remove.addEventListener('click', () => block.remove()); block.append(remove); $return(kind + '-fields').append(block); } function editReturnDocument(doc = null) { editingReturnDocument = doc; $return('review-section').hidden = false; const form = $return('review-form'); form.reset(); for (const name of ['ticker','title','source_url','published_at']) form.elements[name].value = doc?.[name] ?? ''; form.elements.review_evidence.value = doc?.evidence ?? ''; form.elements.ticker.readOnly = !!doc; form.elements.is_withdrawal.checked = !!doc?.is_withdrawal; const parent = form.elements.supersedes_id; parent.replaceChildren(new Option('없음','')); for (const other of returnDocuments) if (other.id !== doc?.id) parent.add(new Option(`${other.ticker} · #${other.id} ${other.title}`,other.id)); parent.value = doc?.supersedes_id ?? ''; $return('event-fields').replaceChildren(); $return('policy-fields').replaceChildren(); for (const item of doc?.events ?? []) addReturnItem('event', item); for (const item of doc?.policies ?? []) addReturnItem('policy', item); $return('audit-output').textContent = ''; $return('show-audit').disabled = !doc; $return('review-section').scrollIntoView({behavior:'smooth'}); } function renderReturnDocuments() { const state = $return('status-filter').value; const period = $return('period-filter').value || 'current'; const byId = new Map(returnDocuments.map(doc => [doc.id,doc])); const yearsById = new Map(returnDocuments.map(doc => [doc.id,returnDocumentYears(doc,byId)])); const tbody = $return('document-list'); tbody.replaceChildren(); const periodDocuments = returnDocuments.filter(doc => matchesReturnPeriod(yearsById.get(doc.id),period)); const shown = periodDocuments.filter(d => state === 'all' || (state === 'active' ? ['active','approved'].includes(d.status) : state === 'reference' ? ['reference','pending'].includes(d.status) : d.status === state)); const labels = {current:`${returnReviewYear}·${returnReviewYear+1}년 대상`,history:`과거 이력 (${returnReviewYear-1}년 이전)`,unknown:'적용 연도 미확인',all:'전체 연도'}; $return('document-status').textContent = `표시 ${shown.length.toLocaleString('ko-KR')}개 · ${labels[period]} ${periodDocuments.length.toLocaleString('ko-KR')}개 · 전체 ${returnDocuments.length.toLocaleString('ko-KR')}개`; if (!shown.length) { const row = document.createElement('tr'); const td = document.createElement('td'); td.colSpan = 6; td.textContent = returnDocuments.length ? '선택한 조건의 검토 자료가 없습니다. 자료 구분 또는 표시 상태를 변경하세요.' : '아직 수집된 검토 자료가 없습니다.'; row.append(td); tbody.append(row); } for (const doc of shown) { const row = document.createElement('tr'); for (const text of [doc.ticker,doc.title, yearsById.get(doc.id).join(' · ') || '미확인', {active:'자동 반영',reference:'근거만 보관',pending:'근거만 보관',approved:'반영 중',excluded:'반영 취소'}[doc.status],doc.error || '수집 완료']) { const td = document.createElement('td'); td.textContent = text; row.append(td); } const td = document.createElement('td'); const button = document.createElement('button'); button.textContent = '근거·수정'; button.addEventListener('click', () => editReturnDocument(doc)); td.append(button); row.append(td); tbody.append(row); const toggle = document.createElement('button'); toggle.textContent = doc.status === 'excluded' ? '다시 반영' : '반영 취소'; toggle.addEventListener('click', async () => { toggle.disabled = true; try { await setReturnSourceStatus(doc.id, doc.status === 'excluded' ? 'restore' : 'cancel'); await loadReturnAdmin(); adminMessage(doc.status === 'excluded' ? '다시 반영했습니다.' : '계산에서 제외했습니다. 원본과 변경 이력은 보존되며 다시 반영할 수 있습니다.'); } catch (error) { adminMessage(error.message); toggle.disabled = false; } }); td.append(toggle); } } async function loadReturnDocuments() { $return('document-status').textContent = '검토 자료를 불러오는 중입니다…'; try { returnDocuments = await returnAdminRequest('documents'); renderReturnDocuments(); } catch (error) { $return('document-status').textContent = '검토 자료를 불러오지 못했습니다. ' + error.message; throw error; } } async function loadReturnSources() { let sources; try { sources = await returnAdminRequest('sources'); } catch (error) { $return('source-list').textContent = 'IR 출처를 불러오지 못했습니다. ' + error.message; throw error; } $return('source-list').replaceChildren(); for (const source of sources) { const p = document.createElement('p'); p.textContent = `${source.ticker} · ${source.url} · ${source.enabled ? '수집 사용' : '수집 중지'} · ${source.error || (source.last_success_at ? '최근 수집 ' + source.last_success_at : '아직 수집되지 않음')}`; const button = document.createElement('button'); button.textContent = '수정'; button.addEventListener('click', () => { const f = $return('source-form'); for (const name of ['ticker','url','is_index','enabled']) f.elements[name].value = String(source[name]); }); p.append(button); $return('source-list').append(p); } } async function loadReturnAdmin() { const results = await Promise.allSettled([loadReturnDocuments(), loadReturnSources()]); const errors = results.filter(r => r.status === 'rejected').map(r => r.reason.message); if (errors.length) throw new Error([...new Set(errors)].join(' · ')); } function readReturnItems(kind) { return [...$return(kind + '-fields').children].map(block => { const item = {}; for (const input of block.querySelectorAll('input,select,textarea')) { item[input.name] = input.type === 'checkbox' ? input.checked : input.type === 'number' ? (input.value === '' ? null : Number(input.value)) : input.value; } if (kind === 'event' && !item.cancellation_date) item.cancellation_date = null; return item; }); } $return('review-form').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget; const payload = {ticker:form.elements.ticker.value, title:form.elements.title.value, source_key:editingReturnDocument?.source_key ?? 'manual:' + crypto.randomUUID(), source_url:form.elements.source_url.value, published_at:form.elements.published_at.value || null, evidence:form.elements.review_evidence.value, status:event.submitter?.value || 'active', supersedes_id:form.elements.supersedes_id.value ? Number(form.elements.supersedes_id.value) : null, is_withdrawal:form.elements.is_withdrawal.checked, events:readReturnItems('event'), policies:readReturnItems('policy')}; const buttons = [...form.querySelectorAll('button')]; buttons.forEach(b => b.disabled = true); try { const saved = await returnAdminRequest('documents' + (editingReturnDocument ? '/' + editingReturnDocument.id : ''), {method:editingReturnDocument ? 'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); await loadReturnAdmin(); editReturnDocument(saved); adminMessage('저장했습니다. 반영 중인 자료는 스크리너에 즉시 적용됩니다.'); } catch (error) { adminMessage(error.message); } finally { buttons.forEach(b => b.disabled = false); } }); $return('source-form').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget; try { await returnAdminRequest('sources', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ ticker:form.elements.ticker.value,url:form.elements.url.value,is_index:form.elements.is_index.value === 'true',enabled:form.elements.enabled.value === 'true'})}); await loadReturnAdmin(); adminMessage('IR 출처를 저장했습니다. 다음 수집 실행부터 적용됩니다.'); } catch (error) { adminMessage(error.message); } }); $return('new-document').addEventListener('click', () => editReturnDocument()); $return('add-event').addEventListener('click', () => addReturnItem('event')); $return('add-policy').addEventListener('click', () => addReturnItem('policy')); $return('status-filter').addEventListener('change', renderReturnDocuments); $return('period-filter').addEventListener('change', () => { const period = $return('period-filter').value; // History includes approved/excluded records; current work defaults to pending. $return('status-filter').value = period === 'history' || period === 'all' ? 'all' : period === 'unknown' ? 'reference' : 'active'; renderReturnDocuments(); }); $return('period-current-label').textContent = `올해·내년 (${returnReviewYear}·${returnReviewYear+1}년)`; $return('period-history-label').textContent = `과거 이력 (${returnReviewYear-1}년 이전)`; $return('show-audit').addEventListener('click', async () => { try { $return('audit-output').textContent = JSON.stringify(await returnAdminRequest(`documents/${editingReturnDocument.id}/audit`),null,2); } catch (error) { adminMessage(error.message); } }); loadReturnAdmin().catch(error => adminMessage(error.message));