// Report.jsx — 보고서 생성 탭. 좌: 보고서 리스트 / 새 보고서 폼 · 우: 보고서 조회·편집.
// ---------------------------------------------------------------- form primitives
function Field({ label, hint, children, req }) {
return (
);
}
// custom dropdown select. options:[{v,l,sub}]
function RSelect({ value, onChange, options, placeholder = '선택', icon }) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
if (!open) return;
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const sel = options.find((o) => o.v === value);
const T_POP = '0 8px 28px rgba(15,23,34,.16)';
return (
{open &&
{options.map((o) => {
const on = o.v === value;
return (
);
})}
}
);
}
const R_POP = '0 8px 28px rgba(15,23,34,.16)';
// searchable ledger / item picker keyed on product type
function LedgerPicker({ product, value, onChange }) {
const [open, setOpen] = React.useState(false);
const [q, setQ] = React.useState('');
const ref = React.useRef(null);
React.useEffect(() => {
if (!open) return;
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const items = React.useMemo(() => {
if (product === '주식') return STOCKS.map((s) => ({ code: s.ticker, ledger: s.ticker, name: s.name, sub: s.sector }));
return BONDS.map((b) => ({ code: b.isin, ledger: b.ledger, name: b.name, sub: b.cat + ' · ' + b.ccy }));
}, [product]);
const filtered = React.useMemo(() => {
if (!q) return items;
const s = q.toLowerCase();
return items.filter((i) => i.name.toLowerCase().includes(s) || i.code.toLowerCase().includes(s) || String(i.ledger).toLowerCase().includes(s));
}, [items, q]);
const sel = items.find((i) => i.code === value);
const codeLabel = product === '주식' ? 'ITEM_CD' : 'LEDGER_NO';
return (
{open &&
{filtered.map((i) => {
const on = i.code === value;
return (
);
})}
{filtered.length === 0 &&
검색 결과가 없어요.
}
}
);
}
function DateField({ value, onChange }) {
return (
onChange(e.target.value)}
style={{ border: 0, outline: 'none', flex: 1, fontFamily: T.font, fontWeight: 600, fontSize: 13, color: T.c800, background: 'transparent' }} />
);
}
// ---------------------------------------------------------------- meta
const REPORT_TYPES = [
{ v: '내부 기록용', l: '내부 기록용', sub: '리스크관리 내부 보관 문서', kind: 'neutral' },
{ v: '감사 대응', l: '감사 대응', sub: '내부·외부 감사 제출 자료', kind: 'blue' },
{ v: '감독원 보고', l: '감독원 보고', sub: '금융감독원 제출 보고서', kind: 'warn' }];
const TYPE_KIND = { '내부 기록용': 'neutral', '감사 대응': 'blue', '감독원 보고': 'warn' };
function fmtEok(v) { return v == null ? '—' : v.toLocaleString('ko-KR') + '억원'; }
function ddayOf(d) { if (!d) return null; const today = new Date(); return Math.round((new Date(d) - today) / 86400000); }
// ---------------------------------------------------------------- report text generator
function buildReport(meta) {
const { type, baseDate, product, item, purpose } = meta;
const today = new Date().toISOString().slice(0, 10);
const bd = baseDate.replace(/-/g, '.');
const statusNarr = {
'위험': '대상 종목의 측정값이 위험 기준을 상회하여 **위험** 등급으로 분류되었습니다. 즉시 한도 점검과 보유 축소·헤지 검토가 필요한 상태입니다.',
'주의': '유보 기준의 80%를 초과하여 **주의** 등급으로 분류되었습니다. 일별 모니터링을 강화하고, 추가 악화 시 보고 체계를 가동합니다.',
'유보': '산출 기준 미충족으로 **유보** 처리되었습니다. 평가 데이터 보완 후 재산출이 필요합니다.',
'정상': '측정 결과 위험 기준 이내로 **정상** 등급입니다. 현행 정기 모니터링 주기를 유지합니다.' };
const statusReco = {
'위험': ['보유 포지션 축소 또는 헤지 전략 검토', '리스크관리위원회 보고 대상으로 상정', '일일 한도 소진율 모니터링으로 전환'],
'주의': ['모니터링 주기를 일별로 전환', '한도 소진율 및 전일대비 변동 추적', '악화 시 Maker-Checker 승인 절차 가동'],
'유보': ['평가 기초데이터 보완 및 정합성 점검', '재산출 일정 등록 후 등급 재판정'],
'정상': ['현 모니터링 체계 유지', '다음 정기 점검 시 재평가'] };
const typeIntro = {
'내부 기록용': '본 보고서는 리스크관리 내부 기록 보관을 목적으로 작성되었습니다.',
'감사 대응': '본 보고서는 내부·외부 감사 대응 자료로 제출하기 위해 작성되었습니다.',
'감독원 보고': '본 보고서는 금융감독원 제출용 보고 자료로 작성되었습니다.' };
const L = [];
L.push(`# ${item.name} 시장리스크 점검 보고서`);
L.push('');
L.push('## 1. 보고 개요');
L.push(`${typeIntro[type]} ${bd} 종가를 기준으로 ${item.name}에 대한 시장리스크 측정 결과를 정리하였습니다.`);
if (purpose && purpose.trim()) { L.push(''); L.push(purpose.trim()); }
L.push('');
L.push('## 2. 대상 종목 정보');
if (product === '채권') {
const dd = ddayOf(item.maturity);
L.push(`• 종목명: ${item.name}`);
L.push(`• ISIN / 원장번호: ${item.isin} / ${item.ledger}`);
L.push(`• 상품 구분: 채권 · ${item.cat}${item.grade ? ` (${item.grade})` : ''}`);
L.push(`• 통화: ${item.ccy}`);
L.push(`• 만기일: ${item.maturity ? item.maturity.replace(/-/g, '.') : '—'}${dd != null ? (dd >= 0 ? ` (D-${dd})` : ' (만기 경과)') : ''}`);
L.push(`• 보유금액: ${fmtEok(item.hold)}`);
} else {
L.push(`• 종목명: ${item.name}`);
L.push(`• 종목코드(ITEM_CD): ${item.ticker}`);
L.push(`• 상품 구분: 주식 · ${item.sector}`);
L.push(`• 평가금액: ${fmtEok(item.eval)}`);
}
L.push('');
L.push('## 3. 리스크 측정 결과');
if (product === '채권') {
const cvar = item.pending ? '산출 보류' : (item.cvar * 100).toFixed(2) + '%';
const dod = item.pending ? '—' : (item.dod >= 0 ? '+' : '') + (item.dod * 100).toFixed(2) + '%p';
L.push(`• CVaR (99%, 10영업일): ${cvar}`);
L.push(`• 전일대비: ${dod}`);
L.push(`• 위험금액: ${fmtEok(item.risk)}`);
} else {
L.push(`• 변동성(연환산): ${item.risk.toFixed(1)}%`);
L.push(`• 평가손익률: ${(item.ret >= 0 ? '+' : '') + item.ret.toFixed(1)}%`);
L.push(`• 전일대비: ${(item.dod >= 0 ? '+' : '') + item.dod.toFixed(1)}%p`);
L.push(`• 위험금액: ${fmtEok(item.riskAmt)}`);
}
L.push(`• 리스크 구분: ${item.status}`);
if (item.note) L.push(`• 비고: ${item.note}`);
L.push('');
L.push('## 4. 평가 의견');
L.push(statusNarr[item.status] || statusNarr['정상']);
L.push('');
L.push('## 5. 조치 및 권고');
(statusReco[item.status] || statusReco['정상']).forEach((r) => L.push('• ' + r));
L.push('');
L.push('---');
L.push(`작성: 리스크총괄부 · 작성일 ${today.replace(/-/g, '.')} · 분류: ${type}`);
L.push('본 문서는 내부 리스크관리 목적의 자동 생성 초안이며, 담당자 검토 후 확정됩니다.');
return L.join('\n');
}
// resolve form item -> data object
function resolveItem(product, code) {
if (product === '주식') return STOCKS.find((s) => s.ticker === code);
return BONDS.find((b) => b.isin === code);
}
// ---------------------------------------------------------------- document renderer
function renderDoc(text) {
const lines = text.split('\n');
const out = [];
let bullets = null;
const flush = () => {
if (bullets) {
out.push();
bullets = null;
}
};
const inline = (s) => {
const parts = s.split(/(\*\*[^*]+\*\*)/g);
return parts.map((p, i) => p.startsWith('**') && p.endsWith('**') ?
{p.slice(2, -2)} : p);
};
lines.forEach((raw, i) => {
const line = raw;
if (line.startsWith('# ')) {
flush();
out.push({line.slice(2)}
);
} else if (line.startsWith('## ')) {
flush();
out.push({line.slice(3)}
);
} else if (line.startsWith('• ') || line.startsWith('- ')) {
const content = line.slice(2);
const m = content.match(/^([^:]+):\s*(.+)$/);
(bullets = bullets || []).push(
•
{m ?
{m[1]}·{m[2]} :
{inline(content)}}
);
} else if (line.trim() === '---') {
flush();
out.push(
);
} else if (line.trim() === '') {
flush();
} else {
flush();
out.push({inline(line)}
);
}
});
flush();
return out;
}
// ---------------------------------------------------------------- persistence + seeds
const RKEY = 'ibk-reports-v1';
function seedReports() {
const today = new Date().toISOString().slice(0, 10);
const mk = (product, code, type, baseDate, purpose, daysAgo) => {
const item = resolveItem(product, code);
if (!item) return null;
const meta = { type, baseDate, product, item, purpose };
const created = new Date(Date.now() - daysAgo * 86400000).toISOString();
return { id: 'r' + Math.random().toString(36).slice(2, 9), title: item.name, type, baseDate, product, code,
itemName: item.name, ledger: product === '주식' ? item.ticker : item.ledger, status: item.status,
purpose, body: buildReport(meta), createdAt: created, updatedAt: created };
};
const b1 = BONDS[0];
const b2 = BONDS.length > 3 ? BONDS[3] : BONDS[0];
const s1 = STOCKS.find((s) => s.status === '위험') || STOCKS[0];
return [
mk('채권', b1.isin, '감독원 보고', today, '매도 권장 신호가 발생한 위험 등급 채권에 대한 감독원 보고 자료입니다.', 0),
mk('주식', s1.ticker, '감사 대응', today, '분기 내부감사 대비 위험 등급 주식의 리스크 산출 근거를 정리합니다.', 1),
mk('채권', b2.isin, '내부 기록용', today, '월말 정기 점검 기록.', 5),
].filter(Boolean);
}
function loadReports() {
try { const raw = localStorage.getItem(RKEY); if (raw) { const a = JSON.parse(raw); if (Array.isArray(a) && a.length > 0) return a; } } catch (e) {}
const s = seedReports();
try { localStorage.setItem(RKEY, JSON.stringify(s)); } catch (e) {}
return s;
}
function saveReports(list) { try { localStorage.setItem(RKEY, JSON.stringify(list)); } catch (e) {} }
function relTime(iso) {
const d = Date.now() - Date.parse(iso);
const day = Math.floor(d / 86400000);
if (day <= 0) return '오늘';
if (day === 1) return '어제';
if (day < 7) return day + '일 전';
return new Date(iso).toLocaleDateString('ko-KR', { month: 'numeric', day: 'numeric' });
}
// ---------------------------------------------------------------- left: list
function ReportList({ reports, activeId, onSelect, onNew }) {
return (
보고서
생성된 보고서 {reports.length}건
새 보고서 생성
{reports.length === 0 &&
아직 생성된 보고서가 없어요.
}
{reports.map((r) => {
const on = r.id === activeId;
return (
);
})}
);
}
// ---------------------------------------------------------------- left: new-report form
function ReportForm({ onCancel, onGenerate, generating }) {
const [type, setType] = React.useState('');
const [baseDate, setBaseDate] = React.useState(new Date().toISOString().slice(0, 10));
const [product, setProduct] = React.useState('');
const [code, setCode] = React.useState('');
const [purpose, setPurpose] = React.useState('');
const ready = type && baseDate && product && code;
const setProd = (p) => { setProduct(p); setCode(''); };
return (
취소
onGenerate({ type, baseDate, product, code, purpose })} style={{ flex: 1 }}>
{generating ? : '생성하기'}
);
}
function RSpinner({ label }) {
return (
{label}
);
}
// ---------------------------------------------------------------- right: viewer / editor
function ReportViewer({ report, editing, draft, onDraft, onToggleEdit, onSave, onRefresh, onDelete, generating, dirty }) {
if (generating) {
return (
보고서를 생성하고 있어요
리스크 측정 결과를 분석하는 중입니다…
);
}
if (!report) {
return (
보고서를 선택하세요
왼쪽에서 기존 보고서를 선택하거나, 새 보고서 생성으로 새 문서를 만들 수 있어요.
);
}
return (
{/* toolbar */}
{report.title}
{report.type}
{editing &&
편집 중}
{report.product}
{report.ledger}
기준일 {report.baseDate.replace(/-/g, '.')}
새로고침
{editing ? '편집 종료' : '편집'}
저장
삭제
{/* body */}
최종 수정 {new Date(report.updatedAt).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
{editing && dirty && 저장되지 않은 변경사항이 있어요}
);
}
// ---------------------------------------------------------------- confirm modal
function RConfirm({ open, title, body, confirmLabel, danger, onConfirm, onCancel }) {
if (!open) return null;
return (
e.stopPropagation()} style={{ width: 380, background: T.white, borderRadius: 16, boxShadow: R_POP, padding: '24px 24px 18px', textAlign: 'left' }}>
{title}
{body}
취소
{confirmLabel}
);
}
// ---------------------------------------------------------------- main
function Report() {
const [reports, setReports] = React.useState(loadReports);
const [activeId, setActiveId] = React.useState(() => { const r = loadReports(); return r[0] ? r[0].id : null; });
const [mode, setMode] = React.useState('list'); // list | form
const [generating, setGenerating] = React.useState(false);
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState('');
const [confirm, setConfirm] = React.useState(null);
const active = reports.find((r) => r.id === activeId) || null;
const dirty = editing && active && draft !== active.body;
const persist = (list) => { setReports(list); saveReports(list); };
const selectReport = (id) => {
if (dirty) { setConfirm({ kind: 'leave', next: id }); return; }
setActiveId(id); setEditing(false); setMode('list');
};
const startNew = () => {
if (dirty) { setConfirm({ kind: 'leaveNew' }); return; }
setEditing(false); setMode('form');
};
const doGenerate = (form) => {
setGenerating(true);
setMode('list');
setActiveId(null);
setTimeout(() => {
const item = resolveItem(form.product, form.code);
const meta = { type: form.type, baseDate: form.baseDate, product: form.product, item, purpose: form.purpose };
const now = new Date().toISOString();
const r = { id: 'r' + Math.random().toString(36).slice(2, 9), title: item.name, type: form.type,
baseDate: form.baseDate, product: form.product, code: form.code,
itemName: item.name, ledger: form.product === '주식' ? item.ticker : item.ledger, status: item.status,
purpose: form.purpose, body: buildReport(meta), createdAt: now, updatedAt: now };
const list = [r, ...reports];
persist(list);
setActiveId(r.id);
setGenerating(false);
setEditing(false);
}, 950);
};
const toggleEdit = () => {
if (editing) {
if (dirty) { setConfirm({ kind: 'discardEdit' }); return; }
setEditing(false);
} else { setDraft(active.body); setEditing(true); }
};
const save = () => {
if (!active) return;
const body = editing ? draft : active.body;
const list = reports.map((r) => r.id === active.id ? { ...r, body, updatedAt: new Date().toISOString() } : r);
persist(list);
setEditing(false);
showToast('저장되었습니다');
};
const refresh = () => {
if (!active) return;
if (dirty) { setConfirm({ kind: 'refresh' }); return; }
runRefresh();
};
const runRefresh = () => {
setGenerating(true);
setTimeout(() => {
const item = resolveItem(active.product, active.code);
const meta = { type: active.type, baseDate: active.baseDate, product: active.product, item, purpose: active.purpose };
const body = buildReport(meta);
const list = reports.map((r) => r.id === active.id ? { ...r, body, updatedAt: new Date().toISOString() } : r);
persist(list);
setEditing(false);
setGenerating(false);
showToast('최신 데이터로 다시 생성했어요');
}, 950);
};
const del = () => setConfirm({ kind: 'delete' });
const runDelete = () => {
const idx = reports.findIndex((r) => r.id === active.id);
const list = reports.filter((r) => r.id !== active.id);
persist(list);
const nextSel = list[idx] || list[idx - 1] || null;
setActiveId(nextSel ? nextSel.id : null);
setEditing(false);
};
const [toast, setToastRaw] = React.useState(null);
const showToast = (msg) => { setToastRaw(msg); setTimeout(() => setToastRaw(null), 1900); };
const resolveConfirm = () => {
const c = confirm; setConfirm(null);
if (!c) return;
if (c.kind === 'delete') runDelete();
else if (c.kind === 'refresh') { setEditing(false); runRefresh(); }
else if (c.kind === 'discardEdit') setEditing(false);
else if (c.kind === 'leave') { setActiveId(c.next); setEditing(false); setMode('list'); }
else if (c.kind === 'leaveNew') { setEditing(false); setMode('form'); }
};
const CONF = {
delete: { title: '보고서를 삭제할까요?', body: '삭제하면 되돌릴 수 없어요. 선택한 보고서가 영구적으로 제거됩니다.', confirmLabel: '삭제', danger: true },
refresh: { title: '다시 생성할까요?', body: '편집한 내용이 사라지고 최신 데이터로 보고서를 다시 생성해요.', confirmLabel: '다시 생성' },
discardEdit: { title: '편집을 종료할까요?', body: '저장하지 않은 변경사항이 사라져요.', confirmLabel: '종료' },
leave: { title: '다른 보고서로 이동할까요?', body: '저장하지 않은 변경사항이 사라져요.', confirmLabel: '이동' },
leaveNew: { title: '새 보고서를 만들까요?', body: '저장하지 않은 변경사항이 사라져요.', confirmLabel: '계속' } };
const cc = confirm ? CONF[confirm.kind] : null;
return (
{mode === 'form' ?
setMode('list')} onGenerate={doGenerate} /> :
}
setConfirm(null)} />
{toast &&
{toast}
}
);
}
Object.assign(window, { Report });