feat(report): 통계 현황 보고서 화면(📈 보고서 탭)
- LouisVuitton /admin/report: 전체 거래 집계(요약·대사·금액·처리시간·기관별) - 처리시간은 stage timestamp 기반 접수→완결 avg/p50/p95/max + 단계별 평균(percentile_cont) · 인과 정합(접수≤순번) 행만 집계해 BMI 재사용 시계역전 건 제외 - 대사: transfer=journal_log=settlement_view 건수 일치 + 총액 보존 검증 - 프론트: Report.tsx 신규 + App.tsx 상단 '📈 보고서' 탭(ADMIN 로그인 게이트) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import AdminPanel from './AdminPanel'
|
||||
import Portal from './Portal'
|
||||
import Report from './Report'
|
||||
|
||||
type Account = { code: string; name: string; balance: number }
|
||||
type Inquiry = Record<string, unknown>
|
||||
@@ -65,7 +66,7 @@ export default function App() {
|
||||
const [notifs, setNotifs] = useState<Record<string, unknown>[]>([])
|
||||
const [msgTab, setMsgTab] = useState<'008' | '002'>('008')
|
||||
|
||||
const [view, setView] = useState<'portal' | 'ops' | 'admin'>('portal')
|
||||
const [view, setView] = useState<'portal' | 'ops' | 'admin' | 'report'>('portal')
|
||||
|
||||
const loadAccounts = async () => {
|
||||
setErr(null)
|
||||
@@ -172,10 +173,13 @@ export default function App() {
|
||||
style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: view === 'ops' ? '#0b63c4' : '#fff', color: view === 'ops' ? '#fff' : '#333' }}>운영 콘솔</button>
|
||||
<button onClick={() => setView('admin')}
|
||||
style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: view === 'admin' ? '#0b63c4' : '#fff', color: view === 'admin' ? '#fff' : '#333' }}>🛠 관리자 (Louis Vuitton)</button>
|
||||
<button onClick={() => setView('report')}
|
||||
style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: view === 'report' ? '#0b63c4' : '#fff', color: view === 'report' ? '#fff' : '#333' }}>📈 보고서</button>
|
||||
</div>
|
||||
|
||||
{view === 'portal' && <Portal onNavigate={setView} />}
|
||||
{view === 'admin' && <AdminPanel />}
|
||||
{view === 'report' && <Report />}
|
||||
|
||||
{view === 'ops' && (<>
|
||||
{/* 자금이체 신청 */}
|
||||
|
||||
139
frontend/src/Report.tsx
Normal file
139
frontend/src/Report.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuth, jget } from './auth'
|
||||
import LoginGate from './AdminLogin'
|
||||
import { won, box, cell, th } from './util'
|
||||
|
||||
/** 통계 현황 보고서 — 전체 거래 집계(요약·대사·금액·처리시간·기관별). ADMIN 전용. */
|
||||
export default function Report() {
|
||||
return <LoginGate><ReportInner /></LoginGate>
|
||||
}
|
||||
|
||||
const n = (v: unknown) => Number(v ?? 0)
|
||||
const num = (v: unknown) => n(v).toLocaleString('ko-KR')
|
||||
const ms = (v: unknown) => `${num(v)} ms`
|
||||
|
||||
function ReportInner() {
|
||||
const { token } = useAuth()
|
||||
const [rep, setRep] = useState<any>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [at, setAt] = useState<number>(0)
|
||||
|
||||
const load = async () => {
|
||||
setErr(null)
|
||||
try { setRep(await jget('/admin/report')); setAt(Date.now()) }
|
||||
catch (e: any) { setErr('보고서 로드 실패: ' + e.message) }
|
||||
}
|
||||
useEffect(() => { if (token) load() }, [token])
|
||||
|
||||
if (err) return <p style={{ color: '#c00', marginTop: 16 }}>{err}</p>
|
||||
if (!rep) return <p style={{ color: '#888', marginTop: 16 }}>보고서 생성 중…</p>
|
||||
|
||||
const t = rep.totals ?? {}
|
||||
const rec = rep.reconciliation ?? {}
|
||||
const amt = rep.amount ?? {}
|
||||
const lat = rep.latency ?? {}
|
||||
const cnt = (st: string) => n((t.byStatus ?? []).find((b: any) => b.status === st)?.cnt)
|
||||
const total = n(t.transfers)
|
||||
const accc = cnt('ACCC'), rjct = cnt('RJCT')
|
||||
const pending = Math.max(0, total - accc - rjct)
|
||||
const rate = total > 0 ? Math.round((accc / total) * 1000) / 10 : 0
|
||||
|
||||
const card: React.CSSProperties = { flex: '1 1 150px', border: '1px solid #e2e2e2', borderRadius: 8, padding: '12px 16px', background: '#fff' }
|
||||
const big: React.CSSProperties = { fontSize: 24, fontWeight: 700, fontFamily: 'monospace' }
|
||||
const lbl: React.CSSProperties = { color: '#777', fontSize: 13, marginBottom: 4 }
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 12 }}>
|
||||
<h2 style={{ margin: 0 }}>📈 통계 현황 보고서</h2>
|
||||
<button onClick={load} style={{ padding: '6px 14px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: '#fff' }}>새로고침</button>
|
||||
{at > 0 && <span style={{ color: '#999', fontSize: 12 }}>생성 {new Date(at).toLocaleString('ko-KR')}</span>}
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 16 }}>
|
||||
<div style={card}><div style={lbl}>총 거래</div><div style={big}>{num(total)}</div></div>
|
||||
<div style={card}><div style={lbl}>완결 (ACCC)</div><div style={{ ...big, color: '#1a7f37' }}>{num(accc)}</div></div>
|
||||
<div style={card}><div style={lbl}>반려 (RJCT)</div><div style={{ ...big, color: '#c00' }}>{num(rjct)}</div></div>
|
||||
<div style={card}><div style={lbl}>미결(대기)</div><div style={{ ...big, color: '#8a6d00' }}>{num(pending)}</div></div>
|
||||
<div style={card}><div style={lbl}>완결률</div><div style={big}>{rate}%</div></div>
|
||||
</div>
|
||||
|
||||
{/* 대사(정합성) */}
|
||||
<section style={{ ...box, background: '#f7fbff' }}>
|
||||
<h3 style={{ marginTop: 0 }}>🔗 대사(정합성)</h3>
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<div>원장(transfer) <b style={{ fontFamily: 'monospace' }}>{num(rec.transferCount)}</b></div>
|
||||
<div>선저널(journal) <b style={{ fontFamily: 'monospace' }}>{num(rec.journalCount)}</b></div>
|
||||
<div>조회사본(view) <b style={{ fontFamily: 'monospace' }}>{num(rec.viewCount)}</b></div>
|
||||
<div>{rec.consistent ? <span style={{ color: '#1a7f37', fontWeight: 600 }}>✓ 계층 일치</span> : <span style={{ color: '#c00', fontWeight: 600 }}>⚠ 불일치</span>}</div>
|
||||
<div style={{ marginLeft: 'auto' }}>총액 <b style={{ fontFamily: 'monospace' }}>{won(n(rec.totalBalance))}</b> {rec.balancePreserved ? <span style={{ color: '#1a7f37' }}>✓ 보존</span> : <span style={{ color: '#c00' }}>⚠ 확인</span>}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 금액 통계 */}
|
||||
<section style={box}>
|
||||
<h3 style={{ marginTop: 0 }}>💰 금액 통계 <span style={{ fontSize: 12, color: '#888' }}>(완결 ACCC {num(amt.count)}건 기준)</span></h3>
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||
<div>합계 <b style={{ fontFamily: 'monospace' }}>{won(n(amt.sum))}</b></div>
|
||||
<div>평균 <b style={{ fontFamily: 'monospace' }}>{won(n(amt.avg))}</b></div>
|
||||
<div>최소 <b style={{ fontFamily: 'monospace' }}>{won(n(amt.min))}</b></div>
|
||||
<div>최대 <b style={{ fontFamily: 'monospace' }}>{won(n(amt.max))}</b></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 처리시간(지연) */}
|
||||
<section style={box}>
|
||||
<h3 style={{ marginTop: 0 }}>⏱️ 처리시간 (지연) <span style={{ fontSize: 12, color: '#888' }}>(완결 {num(lat.count)}건 · 단일 파티션 저널 = 성능 급소)</span></h3>
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<div>접수→완결 평균 <b style={{ fontFamily: 'monospace' }}>{ms(lat.e2e_avg)}</b></div>
|
||||
<div>p50 <b style={{ fontFamily: 'monospace' }}>{ms(lat.e2e_p50)}</b></div>
|
||||
<div>p95 <b style={{ fontFamily: 'monospace' }}>{ms(lat.e2e_p95)}</b></div>
|
||||
<div>최대 <b style={{ fontFamily: 'monospace' }}>{ms(lat.e2e_max)}</b></div>
|
||||
</div>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
<thead><tr><th style={th}>단계</th><th style={{ ...th, textAlign: 'right' }}>평균 지연</th></tr></thead>
|
||||
<tbody>
|
||||
{[
|
||||
['접수 → 순번 (Sequencer)', lat.recv_to_actc],
|
||||
['순번 → 기록 (Dior)', lat.actc_to_pdng],
|
||||
['기록 → 정산 (Hermes)', lat.pdng_to_acsp],
|
||||
['정산 → 완결 (Prada)', lat.acsp_to_accc],
|
||||
].map(([k, v]) => (
|
||||
<tr key={k as string}><td style={cell}>{k}</td><td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace' }}>{ms(v)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{/* 기관별 */}
|
||||
<section style={box}>
|
||||
<h3 style={{ marginTop: 0 }}>🏛 기관별 송·수신 <span style={{ fontSize: 12, color: '#888' }}>(완결 ACCC 기준)</span></h3>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
<thead><tr>
|
||||
<th style={th}>코드</th><th style={th}>기관</th><th style={{ ...th, textAlign: 'right' }}>현재잔액</th>
|
||||
<th style={{ ...th, textAlign: 'right' }}>송신 건</th><th style={{ ...th, textAlign: 'right' }}>송신 금액</th>
|
||||
<th style={{ ...th, textAlign: 'right' }}>수신 건</th><th style={{ ...th, textAlign: 'right' }}>수신 금액</th>
|
||||
<th style={{ ...th, textAlign: 'right' }}>순증감</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{(rep.byInstitution ?? []).map((r: any) => (
|
||||
<tr key={r.code}>
|
||||
<td style={{ ...cell, fontFamily: 'monospace' }}>{r.code}</td>
|
||||
<td style={cell}>{r.name}</td>
|
||||
<td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace' }}>{won(n(r.balance))}</td>
|
||||
<td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace' }}>{num(r.sent_cnt)}</td>
|
||||
<td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace' }}>{won(n(r.sent_amt))}</td>
|
||||
<td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace' }}>{num(r.recv_cnt)}</td>
|
||||
<td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace' }}>{won(n(r.recv_amt))}</td>
|
||||
<td style={{ ...cell, textAlign: 'right', fontFamily: 'monospace', color: n(r.net) >= 0 ? '#1a7f37' : '#c00' }}>{won(n(r.net))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user