StocKing / static /returns-admin.js
adoomy's picture
Add automatic shareholder return estimates and numeric yield sorting
992d9b8
Raw History Blame Contribute Delete
13.8 kB
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));