// 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 &&
setQ(e.target.value)} placeholder={`종목명 또는 ${codeLabel}`} style={{ border: 0, outline: 'none', flex: 1, minWidth: 0, fontFamily: T.font, fontWeight: 500, fontSize: 12.5, color: T.c800, background: 'transparent' }} />
{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 (
    새 보고서 생성
    설정을 입력하고 생성하세요