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:
rtgs
2026-07-16 14:13:55 +09:00
parent c96664e52d
commit 61ced29510
3 changed files with 208 additions and 1 deletions

View File

@@ -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
View 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>
)
}