feat(frontend): 포탈·운영·관리자 콘솔 및 매뉴얼·거래 타임라인·전문탭·서비스 제어 UI

- 콘솔을 포탈/운영/관리자 3뷰로 분리(Portal·AdminPanel·AdminLogin·auth·util), ADMIN JWT 로그인 게이트
- 상단 우측 '서비스 매뉴얼'(public/manual.html) 링크, 로그인 화면 불필요 계좌조회 에러 제거
- 거래 조회: 서비스별 처리시각 5단계 타임라인 + pacs.008/pacs.002 전문 탭(/notifications 프록시)
- 관리자: 서비스 제어 패널(기동/중지/재기동/로그, 4초 폴링, 인프라 상태 포함)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rtgs
2026-07-16 13:41:35 +09:00
parent 1180013506
commit c96664e52d
8 changed files with 933 additions and 167 deletions

View File

@@ -0,0 +1,65 @@
import { useState } from 'react'
import { useAuth, login, changePassword, getUser } from './auth'
import { box } from './util'
/**
* 관리자 로그인 게이트. 인증 전이면 로그인 폼, 초기암호변경 필요 시 변경 폼,
* 그 외에는 children(보호 대상 화면)을 렌더한다. 관리자 패널·포탈이 공유.
*/
export default function LoginGate({ children }: { children: React.ReactNode }) {
const { token, logout } = useAuth()
const [form, setForm] = useState({ username: '', secret: '' })
const [mustChange, setMustChange] = useState(false)
const [newSecret, setNewSecret] = useState('')
const [err, setErr] = useState<string | null>(null)
const [msg, setMsg] = useState<string | null>(null)
const doLogin = async () => {
setErr(null)
const r = await login(form.username, form.secret)
if (!r.ok) { setErr(r.error!); return }
setMustChange(!!r.mustChange) // 성공 시 token이 전역 세팅 → useAuth 재렌더
}
const doChange = async () => {
setErr(null)
if (newSecret.length < 1) { setErr('새 비밀번호를 입력하세요'); return }
const r = await changePassword(getUser(), form.secret, newSecret)
if (!r.ok) { setErr(r.error!); return }
setMsg('비밀번호가 변경되었습니다.'); setMustChange(false)
}
if (!token) {
return (
<section style={{ ...box, maxWidth: 380, margin: '40px auto', background: '#f7fbff' }}>
<h2 style={{ marginTop: 0 }}>🔐 </h2>
<p style={{ color: '#666', fontSize: 13 }}>(LouisVuitton)· <b>ADMIN</b> .</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<input placeholder="아이디" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} style={{ padding: 8 }} />
<input placeholder="비밀번호" type="password" value={form.secret}
onChange={(e) => setForm({ ...form, secret: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter') doLogin() }} style={{ padding: 8 }} />
<button onClick={doLogin} style={{ padding: '10px', background: '#2d6cdf', color: '#fff', border: 0, borderRadius: 6, cursor: 'pointer' }}></button>
</div>
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
</section>
)
}
if (mustChange) {
return (
<section style={{ ...box, maxWidth: 380, margin: '40px auto', background: '#fff8f0' }}>
<h2 style={{ marginTop: 0 }}>🔑 </h2>
<p style={{ color: '#666', fontSize: 13 }}> .</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<input placeholder="새 비밀번호" type="password" value={newSecret}
onChange={(e) => setNewSecret(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') doChange() }} style={{ padding: 8 }} />
<button onClick={doChange} style={{ padding: '10px', background: '#c0392b', color: '#fff', border: 0, borderRadius: 6, cursor: 'pointer' }}></button>
<button onClick={logout} style={{ fontSize: 12 }}></button>
</div>
{msg && <p style={{ color: '#1a7f37', marginTop: 12 }}>{msg}</p>}
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
</section>
)
}
return <>{children}</>
}

View File

@@ -1,52 +1,10 @@
import { useEffect, useState } from 'react'
const won = (n: number) => n.toLocaleString('ko-KR') + '원'
function fmtTs(ms?: unknown): string {
if (typeof ms !== 'number' || !ms) return '-'
const d = new Date(ms); const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
}
const box: React.CSSProperties = { border: '1px solid #e2e2e2', borderRadius: 8, padding: 16, marginTop: 20 }
const cell: React.CSSProperties = { padding: '5px 9px', borderBottom: '1px solid #eee', fontSize: 13 }
const th: React.CSSProperties = { ...cell, borderBottom: '2px solid #ccc', textAlign: 'left', background: '#fafafa' }
type Row = Record<string, any>
/** 정식 pacs.008.001.08 전문 생성(테스트 실행용). */
function buildPacs008(bmi: string, from: string, to: string, amt: number): string {
const now = new Date().toISOString().replace(/\.\d+Z$/, '')
return `<?xml version="1.0" encoding="UTF-8"?><Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08">` +
`<FIToFICstmrCdtTrf><GrpHdr><MsgId>${bmi}</MsgId><CreDtTm>${now}</CreDtTm><NbOfTxs>1</NbOfTxs>` +
`<SttlmInf><SttlmMtd>CLRG</SttlmMtd></SttlmInf></GrpHdr><CdtTrfTxInf><PmtId><EndToEndId>${bmi}</EndToEndId></PmtId>` +
`<IntrBkSttlmAmt Ccy="KRW">${amt}</IntrBkSttlmAmt><ChrgBr>SLEV</ChrgBr>` +
`<Dbtr><Nm>BANK-${from}</Nm></Dbtr><DbtrAcct><Id><Othr><Id>ACC-${from}</Id></Othr></Id></DbtrAcct>` +
`<DbtrAgt><FinInstnId><ClrSysMmbId><MmbId>${from}</MmbId></ClrSysMmbId></FinInstnId></DbtrAgt>` +
`<CdtrAgt><FinInstnId><ClrSysMmbId><MmbId>${to}</MmbId></ClrSysMmbId></FinInstnId></CdtrAgt>` +
`<Cdtr><Nm>BANK-${to}</Nm></Cdtr><CdtrAcct><Id><Othr><Id>ACC-${to}</Id></Othr></Id></CdtrAcct>` +
`</CdtTrfTxInf></FIToFICstmrCdtTrf></Document>`
}
function MiniTable({ title, cols, rows, timeCols = [] }: { title: string; cols: [string, string][]; rows: Row[]; timeCols?: string[] }) {
return (
<div style={{ marginTop: 10 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{title} <span style={{ color: '#888', fontSize: 12 }}>({rows.length})</span></div>
<div style={{ overflowX: 'auto' }}>
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
<thead><tr>{cols.map(([, ko]) => <th key={ko} style={th}>{ko}</th>)}</tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>{cols.map(([k]) => <td key={k} style={{ ...cell, fontFamily: /code|seq|bmi|balance|amount/.test(k) ? 'monospace' : undefined }}>
{timeCols.includes(k) ? fmtTs(r[k]) : String(r[k] ?? '-')}
</td>)}</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
import { useAuth, jget, jsend } from './auth'
import LoginGate from './AdminLogin'
import { won, box, cell, th, MiniTable, buildPacs008, Row } from './util'
export default function AdminPanel() {
const { token, user, logout } = useAuth()
const [summary, setSummary] = useState<any>(null)
const [transfers, setTransfers] = useState<Row[]>([])
const [journal, setJournal] = useState<Row[]>([])
@@ -54,92 +12,74 @@ export default function AdminPanel() {
const [insts, setInsts] = useState<Row[]>([])
const [codes, setCodes] = useState<Row[]>([])
const [users, setUsers] = useState<Row[]>([])
const [health, setHealth] = useState<Row[]>([])
const [services, setServices] = useState<any>(null)
const [svcLog, setSvcLog] = useState<{ id: string; lines: string[] } | null>(null)
const [busySvc, setBusySvc] = useState<string | null>(null)
const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null)
// 인증(A1): Gucci 로그인으로 발급받은 ADMIN JWT
const [token, setToken] = useState<string | null>(() => localStorage.getItem('rtgs_admin_token'))
const [loginUser, setLoginUser] = useState<string>(() => localStorage.getItem('rtgs_admin_user') || '')
const [loginForm, setLoginForm] = useState({ username: '', secret: '' })
const [mustChange, setMustChange] = useState(false)
const [newSecret, setNewSecret] = useState('')
// 폼 상태
const [inst, setInst] = useState({ code: '', name: '', balance: 1000000000 })
const [user, setUser] = useState({ username: '', displayName: '', role: 'ORG_S', orgCode: '' })
const [user2, setUser2] = useState({ username: '', displayName: '', role: 'ORG_S', orgCode: '' })
// 테스트 실행(B6)
const [test, setTest] = useState({ bmiStart: '2026071099000000000001', count: 50 })
const [testRun, setTestRun] = useState<{ running: boolean; done: number; sent: number; rcvd: number; rjct: number; err: number } | null>(null)
const authHeaders = (json = false): Record<string, string> => {
const h: Record<string, string> = {}
if (json) h['Content-Type'] = 'application/json'
if (token) h['Authorization'] = `Bearer ${token}`
return h
}
const logout = () => { localStorage.removeItem('rtgs_admin_token'); localStorage.removeItem('rtgs_admin_user'); setToken(null); setMustChange(false) }
const check401 = (r: Response) => { if (r.status === 401) { logout(); throw new Error('세션 만료 또는 권한 없음 — 다시 로그인하세요') } }
const j = async (u: string) => { const r = await fetch(u, { headers: authHeaders() }); check401(r); return r.json() }
const post = async (url: string, body?: any, method = 'POST') => {
const r = await fetch(url, { method, headers: authHeaders(!!body), body: body ? JSON.stringify(body) : undefined })
check401(r); return r.json()
}
const doLogin = async () => {
setErr(null)
try {
const resp = await fetch('/gucci/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(loginForm) })
if (!resp.ok) { setErr('로그인 실패 — 아이디/비밀번호를 확인하세요'); return }
const r = await resp.json()
if (r.role !== 'ADMIN') { setErr('관리자(ADMIN) 권한이 필요합니다'); return }
localStorage.setItem('rtgs_admin_token', r.token); localStorage.setItem('rtgs_admin_user', loginForm.username)
setLoginUser(loginForm.username); setToken(r.token); setMustChange(!!r.mustChangePassword)
} catch (e: any) { setErr('로그인 실패: ' + e.message) }
}
const doChangePassword = async () => {
setErr(null)
if (newSecret.length < 1) { setErr('새 비밀번호를 입력하세요'); return }
try {
const resp = await fetch('/gucci/auth/change-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: loginUser, oldSecret: loginForm.secret, newSecret }),
})
if (!resp.ok) { setErr('비밀번호 변경 실패'); return }
setMsg('비밀번호가 변경되었습니다.'); setMustChange(false); setNewSecret('')
} catch (e: any) { setErr('변경 실패: ' + e.message) }
}
const loadAll = async () => {
setErr(null)
try {
const [s, t, jn, v, ins, cd, us, hl] = await Promise.all([
j('/admin/summary'), j('/admin/ledger/transfers?limit=15'), j('/admin/ledger/journal?limit=15'),
j('/admin/ledger/views?limit=15'), j('/admin/institutions'), j('/admin/status-codes'), j('/admin/users'), j('/admin/health'),
const [s, t, jn, v, ins, cd, us] = await Promise.all([
jget('/admin/summary'), jget('/admin/ledger/transfers?limit=15'), jget('/admin/ledger/journal?limit=15'),
jget('/admin/ledger/views?limit=15'), jget('/admin/institutions'), jget('/admin/status-codes'), jget('/admin/users'),
])
setSummary(s); setTransfers(t); setJournal(jn); setViews(v); setInsts(ins); setCodes(cd); setUsers(us); setHealth(hl)
setSummary(s); setTransfers(t); setJournal(jn); setViews(v); setInsts(ins); setCodes(cd); setUsers(us)
} catch (e: any) { setErr('관리자 데이터 로드 실패: ' + e.message) }
}
useEffect(() => { if (token && !mustChange) loadAll() }, [token, mustChange])
const loadServices = async () => { try { setServices(await jget('/admin/services')) } catch { /* 폴링 실패 무시 */ } }
useEffect(() => { if (token) loadAll() }, [token])
useEffect(() => {
if (!token) return
loadServices()
const t = setInterval(loadServices, 4000)
return () => clearInterval(t)
}, [token])
// 서비스 제어(기동/중지/재기동)
const svcAction = async (id: string, action: 'start' | 'stop' | 'restart', name: string) => {
if ((action === 'stop' || action === 'restart') && !confirm(`${name} ${action === 'stop' ? '중지' : '재기동'}할까요?`)) return
setBusySvc(id + action); setMsg(null); setErr(null)
try {
const r = await jsend(`/admin/services/${id}/${action}`)
if (r.ok === false) setErr(r.message || '제어 실패'); else setMsg(r.message || '완료')
} catch (e: any) { setErr('제어 실패: ' + e.message) }
finally { setBusySvc(null); setTimeout(loadServices, 900) }
}
const showLog = async (id: string) => {
try { const r = await jget(`/admin/services/${id}/log?lines=150`); setSvcLog({ id, lines: r.lines || [] }) }
catch (e: any) { setErr('로그 조회 실패: ' + e.message) }
}
const ctlBtn = (bg: string, disabled: boolean): React.CSSProperties => ({
padding: '4px 10px', background: disabled ? '#ccc' : bg, color: '#fff', border: 0, borderRadius: 5,
cursor: disabled ? 'default' : 'pointer', fontSize: 12,
})
const doReset = async () => {
if (!confirm('정말 초기화할까요?\n거래/저널/조회사본/원전문을 모두 비우고 계좌 잔액을 각 10억으로 되돌립니다.')) return
setMsg(null); setErr(null)
try { const r = await post('/admin/reset'); setMsg(`초기화 완료: 계좌 ${r.accountsReset}개 리셋, 원장 비움`); await loadAll() }
try { const r = await jsend('/admin/reset'); setMsg(`초기화 완료: 계좌 ${r.accountsReset}개 리셋, 원장 비움`); await loadAll() }
catch (e: any) { setErr('초기화 실패: ' + e.message) }
}
const saveInst = async () => {
if (!/^[0-9]{4}$/.test(inst.code)) { setErr('기관코드는 숫자 4자리'); return }
try { await post('/admin/institutions', inst); setMsg(`기관 저장: ${inst.code}`); await loadAll() } catch (e: any) { setErr('' + e.message) }
try { await jsend('/admin/institutions', inst); setMsg(`기관 저장: ${inst.code}`); await loadAll() } catch (e: any) { setErr('' + e.message) }
}
const delInst = async (code: string) => { if (confirm(`기관 ${code} 삭제?`)) { await post('/admin/institutions/' + code, undefined, 'DELETE'); await loadAll() } }
const delInst = async (code: string) => { if (confirm(`기관 ${code} 삭제?`)) { await jsend('/admin/institutions/' + code, undefined, 'DELETE'); await loadAll() } }
const saveUser = async () => {
if (!user.username) { setErr('사용자ID 필수'); return }
try { await post('/admin/users', { ...user, orgCode: user.orgCode || null }); setMsg(`사용자 저장: ${user.username}`); await loadAll() } catch (e: any) { setErr('' + e.message) }
if (!user2.username) { setErr('사용자ID 필수'); return }
try { await jsend('/admin/users', { ...user2, orgCode: user2.orgCode || null }); setMsg(`사용자 저장: ${user2.username}`); await loadAll() } catch (e: any) { setErr('' + e.message) }
}
const delUser = async (u: string) => { if (confirm(`사용자 ${u} 삭제?`)) { await post('/admin/users/' + u, undefined, 'DELETE'); await loadAll() } }
const delUser = async (u: string) => { if (confirm(`사용자 ${u} 삭제?`)) { await jsend('/admin/users/' + u, undefined, 'DELETE'); await loadAll() } }
// B6: 테스트 이체 대량 생성(송/수신 랜덤, 금액 1,000~1,000,000 랜덤, BMI 시작번호부터 순번 증가)
const BANKS_FALLBACK = ['1001', '1002', '1003', '1004', '1005', '1006', '1007', '1008', '1009', '1010']
@@ -177,56 +117,62 @@ export default function AdminPanel() {
const totalOk = summary && summary.totalBalance === (summary.accounts * 1000000000)
// --- A1: 로그인 게이트 ---
if (!token) {
return (
<section style={{ ...box, maxWidth: 380, margin: '40px auto', background: '#f7fbff' }}>
<h2 style={{ marginTop: 0 }}>🔐 </h2>
<p style={{ color: '#666', fontSize: 13 }}>(LouisVuitton) <b>ADMIN</b> .<br /> : <code>a / 1</code></p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<input placeholder="아이디" value={loginForm.username} onChange={(e) => setLoginForm({ ...loginForm, username: e.target.value })} style={{ padding: 8 }} />
<input placeholder="비밀번호" type="password" value={loginForm.secret}
onChange={(e) => setLoginForm({ ...loginForm, secret: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter') doLogin() }} style={{ padding: 8 }} />
<button onClick={doLogin} style={{ padding: '10px', background: '#2d6cdf', color: '#fff', border: 0, borderRadius: 6, cursor: 'pointer' }}></button>
</div>
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
</section>
)
}
if (mustChange) {
return (
<section style={{ ...box, maxWidth: 380, margin: '40px auto', background: '#fff8f0' }}>
<h2 style={{ marginTop: 0 }}>🔑 </h2>
<p style={{ color: '#666', fontSize: 13 }}> .</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<input placeholder="새 비밀번호" type="password" value={newSecret}
onChange={(e) => setNewSecret(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') doChangePassword() }} style={{ padding: 8 }} />
<button onClick={doChangePassword} style={{ padding: '10px', background: '#c0392b', color: '#fff', border: 0, borderRadius: 6, cursor: 'pointer' }}></button>
<button onClick={logout} style={{ fontSize: 12 }}></button>
</div>
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
</section>
)
}
return (
<LoginGate>
<div>
<div style={{ textAlign: 'right', color: '#666', fontSize: 13, marginTop: 8 }}>
👤 <b>{loginUser}</b> (ADMIN) <button onClick={logout} style={{ fontSize: 12, marginLeft: 8 }}></button>
👤 <b>{user}</b> (ADMIN) <button onClick={logout} style={{ fontSize: 12, marginLeft: 8 }}></button>
</div>
{/* 서비스 상태(B5 하트비트) */}
{/* 서비스 제어 (기동/중지/재기동 · 하트비트 상태 · 4초 갱신) */}
<section style={{ ...box, background: '#f7fff7' }}>
<h2 style={{ marginTop: 0 }}>🩺 <span style={{ fontSize: 12, color: '#888' }}>( · )</span></h2>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{health.length === 0 && <span style={{ color: '#888' }}> </span>}
{health.map((h) => (
<span key={String(h.service) + h.center} style={{ padding: '4px 10px', borderRadius: 6, fontSize: 13, background: h.status === 'UP' ? '#e6f4ea' : '#fde8e8', border: '1px solid ' + (h.status === 'UP' ? '#8bd3a0' : '#f0a0a0') }}>
{h.status === 'UP' ? '🟢' : '🔴'} <b>{String(h.service)}</b> <span style={{ color: '#666' }}>({String(h.center)}·pid {String(h.pid)}·{Math.round(Number(h.ageMs) / 1000)}s)</span>
</span>
))}
</div>
<h2 style={{ marginTop: 0 }}>🖥 <span style={{ fontSize: 12, color: '#888' }}>(·· · · 4 )</span></h2>
{services?.controlEnabled === false && <p style={{ color: '#b58900', margin: '4px 0' }}> (rtgs.control.enabled=false) </p>}
{!services && <span style={{ color: '#888' }}> </span>}
{services && (
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
<thead><tr>
<th style={th}></th><th style={th}></th><th style={th}></th><th style={th}>PID</th><th style={th}></th>
</tr></thead>
<tbody>
{(services.services ?? []).map((s: any) => (
<tr key={s.id}>
<td style={cell}>
{s.status === 'UP' ? '🟢 UP' : '🔴 DOWN'}
{s.status === 'UP' && s.ageMs != null && <span style={{ color: '#888', fontSize: 11 }}> · {Math.round(s.ageMs / 1000)}s</span>}
</td>
<td style={{ ...cell, fontWeight: 600 }}>{s.name}</td>
<td style={{ ...cell, fontFamily: 'monospace' }}>:{s.port}</td>
<td style={{ ...cell, fontFamily: 'monospace', color: '#888' }}>{s.pid ?? '-'}</td>
<td style={cell}>
{s.controllable ? (
<span style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button disabled={s.status === 'UP' || busySvc !== null} onClick={() => svcAction(s.id, 'start', s.name)} style={ctlBtn('#1a7f37', s.status === 'UP' || busySvc !== null)}></button>
<button disabled={s.status !== 'UP' || busySvc !== null} onClick={() => svcAction(s.id, 'stop', s.name)} style={ctlBtn('#c0392b', s.status !== 'UP' || busySvc !== null)}></button>
<button disabled={s.status !== 'UP' || busySvc !== null} onClick={() => svcAction(s.id, 'restart', s.name)} style={ctlBtn('#2d6cdf', s.status !== 'UP' || busySvc !== null)}></button>
<button disabled={busySvc !== null} onClick={() => showLog(s.id)} style={ctlBtn('#666', busySvc !== null)}></button>
</span>
) : <span style={{ color: '#999', fontSize: 12 }}> ( )</span>}
</td>
</tr>
))}
{(services.infra ?? []).map((f: any) => (
<tr key={f.id} style={{ background: '#fafafa' }}>
<td style={cell}>{f.reachable ? '🟢 UP' : '🔴 DOWN'}</td>
<td style={{ ...cell, fontWeight: 600 }}>{f.name} <span style={{ fontSize: 11, color: '#999' }}>()</span></td>
<td style={{ ...cell, fontFamily: 'monospace' }}>:{f.port}</td>
<td style={cell}>-</td>
<td style={{ ...cell, color: '#999', fontSize: 12 }}> ( )</td>
</tr>
))}
</tbody>
</table>
)}
{svcLog && (
<details open style={{ marginTop: 10 }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>📜 {svcLog.id}.log ( {svcLog.lines.length}) <button onClick={() => setSvcLog(null)} style={{ fontSize: 11, marginLeft: 8 }}></button></summary>
<pre style={{ background: '#0d1117', color: '#c9d1d9', padding: 12, marginTop: 8, maxHeight: 300, overflow: 'auto', fontSize: 12 }}>{svcLog.lines.join('\n') || '(로그 없음)'}</pre>
</details>
)}
</section>
{/* 대사 대시보드 */}
@@ -283,12 +229,12 @@ export default function AdminPanel() {
<section style={box}>
<h2 style={{ marginTop: 0 }}>👤 <span style={{ fontSize: 12, color: '#888' }}>( · ADMIN )</span></h2>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
<label>ID<br /><input value={user.username} onChange={(e) => setUser({ ...user, username: e.target.value })} style={{ padding: 6, width: 120 }} /></label>
<label><br /><input value={user.displayName} onChange={(e) => setUser({ ...user, displayName: e.target.value })} style={{ padding: 6, width: 140 }} /></label>
<label><br /><select value={user.role} onChange={(e) => setUser({ ...user, role: e.target.value })} style={{ padding: 6 }}>
<label>ID<br /><input value={user2.username} onChange={(e) => setUser2({ ...user2, username: e.target.value })} style={{ padding: 6, width: 120 }} /></label>
<label><br /><input value={user2.displayName} onChange={(e) => setUser2({ ...user2, displayName: e.target.value })} style={{ padding: 6, width: 140 }} /></label>
<label><br /><select value={user2.role} onChange={(e) => setUser2({ ...user2, role: e.target.value })} style={{ padding: 6 }}>
<option value="ADMIN">ADMIN()</option><option value="ORG_S">ORG_S()</option><option value="ORG_R">ORG_R()</option>
</select></label>
<label><br /><input value={user.orgCode} onChange={(e) => setUser({ ...user, orgCode: e.target.value })} placeholder="1001" style={{ padding: 6, width: 90, fontFamily: 'monospace' }} /></label>
<label><br /><input value={user2.orgCode} onChange={(e) => setUser2({ ...user2, orgCode: e.target.value })} placeholder="1001" style={{ padding: 6, width: 90, fontFamily: 'monospace' }} /></label>
<button onClick={saveUser} style={{ padding: '8px 16px' }}>/</button>
</div>
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
@@ -322,5 +268,6 @@ export default function AdminPanel() {
{msg && <p style={{ color: '#1a7f37', marginTop: 12 }}>{msg}</p>}
{err && <p style={{ color: '#c00', marginTop: 12 }}>{err}</p>}
</div>
</LoginGate>
)
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import AdminPanel from './AdminPanel'
import Portal from './Portal'
type Account = { code: string; name: string; balance: number }
type Inquiry = Record<string, unknown>
@@ -26,6 +27,16 @@ const STATUS_KO: Record<string, string> = {
RCVD: '접수', ACTC: '승인', PDNG: '대기', ACSP: '예약', ACCC: '입금처리완료', RJCT: '반려', IN_FLIGHT: '처리중',
}
const TIME_FIELDS = new Set(['updatedAt', 'createdAt'])
// 서비스별 처리시각 타임라인(한 거래가 거치는 5개 서비스). inquiry 응답의 epoch(ms) 필드와 매핑.
const TIMELINE_STAGES: { key: string; label: string; svc: string; emoji: string }[] = [
{ key: 'rcvdAt', label: '접수', svc: 'Chanel', emoji: '📥' },
{ key: 'actcAt', label: '순번', svc: 'Sequencer', emoji: '🔢' },
{ key: 'pdngAt', label: '기록', svc: 'Dior', emoji: '🔄' },
{ key: 'acspAt', label: '정산', svc: 'Hermes', emoji: '💰' },
{ key: 'acccAt', label: '완결', svc: 'Prada', emoji: '✅' },
]
// 타임라인으로 별도 표기하므로 상세표에서는 숨김.
const TIMELINE_KEYS = new Set(TIMELINE_STAGES.map((s) => s.key))
const statusColor = (s?: string) =>
s === 'ACCC' ? '#1a7f37' : s === 'RJCT' ? '#c00' : s ? '#8a6d00' : '#555'
@@ -51,8 +62,10 @@ export default function App() {
const [bmi, setBmi] = useState('')
const [inquiry, setInquiry] = useState<Inquiry | null>(null)
const [rawXml, setRawXml] = useState<string | null>(null)
const [notifs, setNotifs] = useState<Record<string, unknown>[]>([])
const [msgTab, setMsgTab] = useState<'008' | '002'>('008')
const [view, setView] = useState<'ops' | 'admin'>('ops')
const [view, setView] = useState<'portal' | 'ops' | 'admin'>('portal')
const loadAccounts = async () => {
setErr(null)
@@ -109,22 +122,25 @@ export default function App() {
}
const doInquiry = async () => {
setErr(null); setInquiry(null); setRawXml(null)
setErr(null); setInquiry(null); setRawXml(null); setNotifs([]); setMsgTab('008')
const key = bmi.trim()
if (!key) return
try {
const [inq, raw] = await Promise.all([
const [inq, raw, nts] = await Promise.all([
fetch('/inquiry/' + encodeURIComponent(key)).then((r) => r.json()),
fetch('/rawmessage/' + encodeURIComponent(key)).then((r) => r.json()).catch(() => null),
fetch('/notifications/' + encodeURIComponent(key)).then((r) => r.json()).catch(() => []),
])
setInquiry(inq)
setRawXml(raw?.found ? raw.rawXml : null)
setNotifs(Array.isArray(nts) ? nts : [])
} catch (e: any) {
setErr('거래 조회 실패: ' + e.message)
}
}
useEffect(() => { loadAccounts(); loadLabels() }, [])
// 계좌·라벨은 운영 콘솔에서만 쓰이므로 해당 탭 진입 시 로드(로그인/포탈 화면에서 불필요한 Chanel 호출·에러 방지)
useEffect(() => { if (view === 'ops') { loadAccounts(); loadLabels() } }, [view])
const box: React.CSSProperties = { border: '1px solid #e2e2e2', borderRadius: 8, padding: 16, marginTop: 20 }
const cell: React.CSSProperties = { padding: '6px 10px', borderBottom: '1px solid #eee' }
@@ -140,16 +156,25 @@ export default function App() {
}
return (
<div style={{ fontFamily: 'system-ui, sans-serif', maxWidth: 900, margin: '2rem auto', padding: '0 1rem' }}>
<h1>RTGS <span style={{ fontSize: 14, color: '#888' }}>(DC1 · :5174)</span></h1>
<div style={{ fontFamily: 'system-ui, sans-serif', margin: '1.5rem', padding: '0 0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<h1 style={{ margin: 0 }}>RTGS <span style={{ fontSize: 14, color: '#888' }}>(DC1 · :5174)</span></h1>
<a href="/manual.html" target="_blank" rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 14px', border: '1px solid #ccc', borderRadius: 6, background: '#fff', color: '#333', textDecoration: 'none', fontSize: 14, fontWeight: 600 }}>
📖
</a>
</div>
<div style={{ display: 'flex', gap: 8, margin: '12px 0' }}>
<button onClick={() => setView('portal')}
style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: view === 'portal' ? '#0b63c4' : '#fff', color: view === 'portal' ? '#fff' : '#333' }}>🏛 </button>
<button onClick={() => setView('ops')}
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>
</div>
{view === 'portal' && <Portal onNavigate={setView} />}
{view === 'admin' && <AdminPanel />}
{view === 'ops' && (<>
@@ -208,7 +233,7 @@ export default function App() {
</tr>
</thead>
<tbody>
{Object.entries(inquiry).filter(([k]) => k !== 'note').map(([k, v]) => (
{Object.entries(inquiry).filter(([k]) => k !== 'note' && !TIMELINE_KEYS.has(k)).map(([k, v]) => (
<tr key={k}>
<td style={{ ...cell, fontWeight: 600 }}>{labels[k] ?? k}</td>
<td style={{ ...cell, color: '#888', fontFamily: 'monospace' }}>{k}</td>
@@ -219,13 +244,69 @@ export default function App() {
</table>
)}
{rawXml && (
<details style={{ marginTop: 12 }} open>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>📄 ISO20022 pacs.008 </summary>
<pre style={{ background: '#f5f5f5', padding: 12, marginTop: 8, overflowX: 'auto', fontSize: 13 }}>{prettyXml(rawXml)}</pre>
</details>
{/* 서비스별 처리시각 타임라인(접수→순번→기록→정산→완결) */}
{inquiry && !(inquiry as any).note && (
<div style={{ marginTop: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>🕘 </div>
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ccc', textAlign: 'left' }}>
<th style={{ ...cell, width: 90 }}></th>
<th style={{ ...cell, width: 130 }}></th>
<th style={cell}> (YYYYMMDDHH24MISS)</th>
</tr>
</thead>
<tbody>
{TIMELINE_STAGES.map((s) => {
const v = (inquiry as any)[s.key]
const has = typeof v === 'number' && v > 0
return (
<tr key={s.key} style={{ color: has ? undefined : '#bbb' }}>
<td style={{ ...cell, fontWeight: 600 }}>{s.emoji} {s.label}</td>
<td style={cell}>{s.svc}</td>
<td style={{ ...cell, fontFamily: 'monospace' }}>{has ? fmtTs(v) : '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* 관련 전문 탭: pacs.008(원문) / pacs.002(결과통보) */}
{inquiry && !(inquiry as any).note && (
<div style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 6, borderBottom: '1px solid #ddd' }}>
{([['008', '📄 pacs.008 (원문)'], ['002', `📨 pacs.002 (결과통보${notifs.length ? ` ${notifs.length}` : ''})`]] as const).map(([id, label]) => (
<button key={id} onClick={() => setMsgTab(id)}
style={{ padding: '8px 14px', border: 0, borderBottom: msgTab === id ? '3px solid #0b63c4' : '3px solid transparent',
background: 'transparent', cursor: 'pointer', fontWeight: msgTab === id ? 700 : 400, color: msgTab === id ? '#0b63c4' : '#555' }}>
{label}
</button>
))}
</div>
{msgTab === '008' && (rawXml
? <pre style={{ background: '#f5f5f5', padding: 12, marginTop: 8, overflowX: 'auto', fontSize: 13 }}>{prettyXml(rawXml)}</pre>
: <p style={{ color: '#888', marginTop: 8 }}>( pacs.008 )</p>)}
{msgTab === '002' && (notifs.length > 0
? notifs.map((n, i) => (
<div key={i} style={{ marginTop: 10, border: '1px solid #e2e2e2', borderRadius: 6, overflow: 'hidden' }}>
<div style={{ background: '#f4f7fb', padding: '8px 12px', fontSize: 13, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<span> <b>{String(n.toOrg ?? '-')}</b></span>
<span> <b>{n.role === 'APPLICANT' ? '신청기관' : n.role === 'BENEFICIARY' ? '수취기관' : String(n.role ?? '-')}</b></span>
<span> <b style={{ color: statusColor(String(n.finalStatus)) }}>{String(n.finalStatus ?? '-')}</b></span>
<span> {n.delivered ? '✅ 완료' : '⏳ 대기'}</span>
</div>
{n.pacs002
? <pre style={{ background: '#f5f5f5', padding: 12, margin: 0, overflowX: 'auto', fontSize: 13 }}>{prettyXml(String(n.pacs002))}</pre>
: <p style={{ color: '#888', padding: 12, margin: 0 }}>(pacs.002 )</p>}
</div>
))
: <p style={{ color: '#888', marginTop: 8 }}>( )</p>)}
</div>
)}
{inquiry && rawXml === null && <p style={{ color: '#888', marginTop: 8 }}>( )</p>}
</section>
{/* 계좌 잔액 */}

211
frontend/src/Portal.tsx Normal file
View File

@@ -0,0 +1,211 @@
import { useEffect, useRef, useState } from 'react'
import { useAuth, jget } from './auth'
import LoginGate from './AdminLogin'
import { box } from './util'
type View = 'portal' | 'ops' | 'admin'
type Health = { service: string; center: string; pid: number; lastSeen: number; ageMs: number; status: string }
type SvcMetric = { service: string; port: number; reachable: boolean; metrics: Record<string, any> }
/** 6개 주 서비스 카드 메타(순번기는 흐름도 진입 노드로만 표기). 이름은 영문, 역할 설명은 한글 유지. */
const SERVICES: { id: string; name: string; role: string; emoji: string; color: string }[] = [
{ id: 'gucci', name: 'Gucci', role: '게이트웨이 · 기관 인증', emoji: '🛡️', color: '#6b4fbb' },
{ id: 'chanel', name: 'Chanel', role: '접수', emoji: '📥', color: '#0b63c4' },
{ id: 'hermes', name: 'Hermes', role: '결제 · 원장 엔진', emoji: '💰', color: '#c77d0a' },
{ id: 'dior', name: 'Dior', role: '센터간 접수 동기화', emoji: '🔄', color: '#8a4fd0' },
{ id: 'prada', name: 'Prada', role: '센터간 처리 동기화 · 완결', emoji: '✅', color: '#b8860b' },
{ id: 'louisvuitton', name: 'Louis Vuitton', role: '관리자 · 운영', emoji: '🛠️', color: '#1a7f37' },
]
/** 업무 흐름도 노드(순번기→관문→접수→동기화→결제→처리동기화→통보). 서비스명 영문, 단계 설명 한글. */
const FLOW: { id: string; label: string }[] = [
{ id: 'sequencer', label: 'Sequencer' }, { id: 'gucci', label: 'Gucci(관문)' }, { id: 'chanel', label: 'Chanel(접수)' },
{ id: 'dior', label: 'Dior(동기화)' }, { id: 'hermes', label: 'Hermes(결제)' }, { id: 'prada', label: 'Prada(처리동기화)' },
{ id: 'chanel', label: '결과통보' },
]
const ALL_IDS = ['sequencer', 'gucci', 'chanel', 'dior', 'hermes', 'prada', 'louisvuitton']
const stColor = (s: string) => (s === 'UP' ? '#1a7f37' : s === 'STALE' ? '#b58900' : '#c0392b')
const stDot = (s: string) => (s === 'UP' ? '🟢' : s === 'STALE' ? '🟡' : '🔴')
export default function Portal({ onNavigate }: { onNavigate?: (v: View) => void }) {
return <LoginGate><PortalInner onNavigate={onNavigate} /></LoginGate>
}
function PortalInner({ onNavigate }: { onNavigate?: (v: View) => void }) {
const { token } = useAuth()
const [health, setHealth] = useState<Health[]>([])
const [metrics, setMetrics] = useState<SvcMetric[]>([])
const [summary, setSummary] = useState<any>(null)
const [core, setCore] = useState<string>('?') // Gucci가 본 코어(Chanel) 상태
const [err, setErr] = useState<string | null>(null)
const [auto, setAuto] = useState(true)
const [at, setAt] = useState<number>(0)
const prevTx = useRef<{ n: number; at: number } | null>(null)
const [tps, setTps] = useState(0)
const load = async () => {
setErr(null)
try {
const [h, m, s, c] = await Promise.all([
jget('/admin/health'),
jget('/admin/metrics/summary'),
jget('/admin/summary'),
jget('/gucci/health/centers').catch(() => null),
])
setHealth(h); setMetrics(m?.services ?? []); setSummary(s)
setCore(c?.centers?.[0]?.['core(chanel)'] ?? '?')
const now = Date.now()
const txn = Number(s?.transfers ?? 0)
if (prevTx.current) {
const dt = (now - prevTx.current.at) / 1000
if (dt > 0) setTps(Math.max(0, Math.round(((txn - prevTx.current.n) / dt) * 10) / 10))
}
prevTx.current = { n: txn, at: now }
setAt(now)
} catch (e: any) { setErr('포탈 데이터 로드 실패: ' + e.message) }
}
useEffect(() => { if (token) load() }, [token])
useEffect(() => {
if (!auto || !token) return
const t = setInterval(load, 5000)
return () => clearInterval(t)
}, [auto, token])
const hMap: Record<string, Health> = Object.fromEntries(health.map((h) => [h.service, h]))
const mMap: Record<string, SvcMetric> = Object.fromEntries(metrics.map((m) => [m.service, m]))
const statusOf = (id: string): string => {
const h = hMap[id]
if (h) return h.status
if (mMap[id]?.reachable) return 'UP'
return 'DOWN'
}
const upCount = ALL_IDS.filter((id) => statusOf(id) === 'UP').length
/** transfer 상태별 건수(DB 집계 /admin/summary.byStatus). 초기화 시 자연히 0. */
const statusCount = (st: string): number => {
const row = (summary?.byStatus ?? []).find((r: any) => r.status === st)
return row ? Number(row.cnt) : 0
}
/** 서비스별 지표 라인. 처리 건수는 DB 집계 기반(초기화 반영), 성능/자원은 런타임 지표. */
const metricLines = (id: string): { label: string; value: string }[] => {
const m = mMap[id]?.metrics ?? {}
const acccN = statusCount('ACCC')
switch (id) {
case 'gucci':
return [{ label: '코어(샤넬)', value: core }, { label: '등록기관', value: String(summary?.accounts ?? '-') }]
case 'chanel':
return [{ label: '누적 접수', value: String(summary?.transfers ?? '-') }, { label: '접수율', value: `${tps} 건/s` }]
case 'hermes':
return [
{ label: '결제완료(ACCC)', value: String(acccN) },
{ label: '평균 처리', value: `${m.settleMeanMs ?? 0} ms` },
{ label: '미결(대기)', value: String(Math.max(0, Number(summary?.transfers ?? 0) - acccN - statusCount('RJCT'))) },
]
case 'prada':
return [{ label: '완결(ACCC)', value: String(acccN) }, { label: '반려(RJCT)', value: String(statusCount('RJCT')) }]
case 'dior':
return [{ label: '메모리', value: m.memUsedMb != null ? `${m.memUsedMb} MB` : '-' }, { label: '가동', value: m.uptimeSec != null ? `${m.uptimeSec}s` : '-' }]
case 'louisvuitton':
return [{ label: '총 거래', value: String(summary?.transfers ?? '-') }, { label: '최대순번', value: String(summary?.maxSeq ?? '-') }]
default:
return []
}
}
/** 서비스별 바로가기 버튼. */
const actions = (id: string): { label: string; onClick: () => void }[] => {
switch (id) {
case 'gucci':
return [{ label: '감사로그(Kibana)', onClick: () => window.open('http://localhost:5601', '_blank') }]
case 'chanel':
return [{ label: '이체 신청/조회', onClick: () => onNavigate?.('ops') }]
case 'hermes':
return [{ label: '원장 보기', onClick: () => onNavigate?.('admin') }]
case 'dior':
return [{ label: '원장 보기', onClick: () => onNavigate?.('admin') }]
case 'prada':
return [{ label: '완결/조회사본', onClick: () => onNavigate?.('admin') }]
case 'louisvuitton':
return [
{ label: '관리자 콘솔', onClick: () => onNavigate?.('admin') },
{ label: '로그(Kibana)', onClick: () => window.open('http://localhost:5601', '_blank') },
]
default:
return []
}
}
const btn: React.CSSProperties = { padding: '6px 12px', border: '1px solid #ccc', borderRadius: 6, cursor: 'pointer', background: '#fff', fontSize: 13 }
return (
<div>
{/* 상단 바 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', marginTop: 8, padding: '10px 14px', background: '#f4f7fb', borderRadius: 8 }}>
<b style={{ fontSize: 15 }}>🏛 RTGS </b>
<span style={{ color: '#666' }}>DC1</span>
<span style={{ color: stColor(upCount === ALL_IDS.length ? 'UP' : upCount === 0 ? 'DOWN' : 'STALE'), fontWeight: 600 }}>
{upCount}/{ALL_IDS.length} UP
</span>
<button onClick={load} style={btn}></button>
<label style={{ fontSize: 13, color: '#444' }}>
<input type="checkbox" checked={auto} onChange={(e) => setAuto(e.target.checked)} /> 5s
</label>
{at > 0 && <span style={{ color: '#999', fontSize: 12 }}> {new Date(at).toLocaleTimeString('ko-KR')}</span>}
</div>
{/* 업무 흐름도 — 창 폭에 맞춰 결과통보까지 한 화면에(가로 스크롤 없음). */}
<section style={{ ...box }}>
<div style={{ fontWeight: 600, marginBottom: 10 }}> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, width: '100%' }}>
{FLOW.map((n, i) => {
const s = statusOf(n.id)
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0, padding: '8px 6px', borderRadius: 8, border: `2px solid ${stColor(s)}`, background: '#fff', textAlign: 'center' }}>
<div style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{stDot(s)} {n.label}</div>
</div>
{i < FLOW.length - 1 && <span style={{ color: '#999', fontSize: 18, flex: '0 0 auto' }}></span>}
</div>
)
})}
</div>
<div style={{ color: '#888', fontSize: 12, marginTop: 8 }}>(Sequencer) (Gucci) (Chanel) / (Dior) ·(Hermes) ·(Prada) (Chanel)</div>
</section>
{/* 서비스 카드 6장 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 14, marginTop: 20 }}>
{SERVICES.map((svc) => {
const s = statusOf(svc.id)
const h = hMap[svc.id]
return (
<div key={svc.id} style={{ border: `1px solid #e2e2e2`, borderTop: `4px solid ${svc.color}`, borderRadius: 8, padding: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 700 }}>{svc.emoji} {svc.name}</div>
<span style={{ fontSize: 12, fontWeight: 600, color: stColor(s) }}>{stDot(s)} {s}</span>
</div>
<div style={{ color: '#666', fontSize: 13, margin: '2px 0 6px' }}>{svc.role}</div>
<div style={{ color: '#999', fontSize: 11, fontFamily: 'monospace' }}>
:{mMap[svc.id]?.port ?? '-'}{h ? ` · pid ${h.pid} · ${Math.round(h.ageMs / 1000)}s` : ' · 하트비트 없음'}
</div>
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 3 }}>
{metricLines(svc.id).map((ml, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
<span style={{ color: '#777' }}>{ml.label}</span><b style={{ fontFamily: 'monospace' }}>{ml.value}</b>
</div>
))}
</div>
<div style={{ marginTop: 12, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{actions(svc.id).map((a, i) => <button key={i} onClick={a.onClick} style={btn}>{a.label}</button>)}
</div>
</div>
)
})}
</div>
{err && <p style={{ color: '#c00', marginTop: 16 }}>{err}</p>}
</div>
)
}

76
frontend/src/auth.ts Normal file
View File

@@ -0,0 +1,76 @@
// 관리자(ADMIN JWT) 인증 공용 모듈 — 관리자 패널·포탈이 공유.
// 토큰은 Gucci(/gucci/auth/login)에서 발급받아 localStorage에 보관하고, /admin/* 호출에 Bearer로 첨부한다.
import { useEffect, useState } from 'react'
const TOKEN_KEY = 'rtgs_admin_token'
const USER_KEY = 'rtgs_admin_user'
type Listener = () => void
const listeners = new Set<Listener>()
const emit = () => listeners.forEach((l) => l())
export const getToken = () => localStorage.getItem(TOKEN_KEY)
export const getUser = () => localStorage.getItem(USER_KEY) || ''
export function setSession(token: string, user: string) {
localStorage.setItem(TOKEN_KEY, token); localStorage.setItem(USER_KEY, user); emit()
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); emit()
}
export function authHeader(json = false): Record<string, string> {
const h: Record<string, string> = {}
if (json) h['Content-Type'] = 'application/json'
const t = getToken(); if (t) h['Authorization'] = `Bearer ${t}`
return h
}
/** 401이면 토큰을 비우고 예외. */
export async function authedFetch(url: string, init: RequestInit = {}): Promise<Response> {
const r = await fetch(url, init)
if (r.status === 401) { clearToken(); throw new Error('세션 만료 또는 권한 없음 — 다시 로그인하세요') }
return r
}
export async function jget(url: string): Promise<any> {
const r = await authedFetch(url, { headers: authHeader() }); return r.json()
}
export async function jsend(url: string, body?: any, method = 'POST'): Promise<any> {
const r = await authedFetch(url, { method, headers: authHeader(!!body), body: body ? JSON.stringify(body) : undefined })
return r.json()
}
/** 로그인 → ADMIN 검증 → 세션 저장. 성공 시 {ok:true, mustChange}. */
export async function login(username: string, secret: string): Promise<{ ok: boolean; mustChange?: boolean; error?: string }> {
try {
const resp = await fetch('/gucci/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, secret }),
})
if (!resp.ok) return { ok: false, error: '로그인 실패 — 아이디/비밀번호를 확인하세요' }
const r = await resp.json()
if (r.role !== 'ADMIN') return { ok: false, error: '관리자(ADMIN) 권한이 필요합니다' }
setSession(r.token, username)
return { ok: true, mustChange: !!r.mustChangePassword }
} catch (e: any) { return { ok: false, error: '로그인 실패: ' + e.message } }
}
export async function changePassword(username: string, oldSecret: string, newSecret: string): Promise<{ ok: boolean; error?: string }> {
try {
const resp = await fetch('/gucci/auth/change-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, oldSecret, newSecret }),
})
if (!resp.ok) return { ok: false, error: '비밀번호 변경 실패' }
return { ok: true }
} catch (e: any) { return { ok: false, error: '변경 실패: ' + e.message } }
}
/** 토큰 상태를 구독하는 훅(여러 컴포넌트 동기화). */
export function useAuth() {
const [token, setToken] = useState<string | null>(() => getToken())
useEffect(() => {
const l = () => setToken(getToken())
listeners.add(l)
return () => { listeners.delete(l) }
}, [])
return { token, user: getUser(), logout: clearToken }
}

50
frontend/src/util.tsx Normal file
View File

@@ -0,0 +1,50 @@
// 운영 콘솔/관리자/포탈 공용 포맷·스타일·테이블·전문생성 유틸.
export const won = (n: number) => n.toLocaleString('ko-KR') + '원'
/** epoch(ms) -> YYYYMMDDHH24MISS (14자리). */
export function fmtTs(ms?: unknown): string {
if (typeof ms !== 'number' || !ms) return '-'
const d = new Date(ms); const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
}
export const box: React.CSSProperties = { border: '1px solid #e2e2e2', borderRadius: 8, padding: 16, marginTop: 20 }
export const cell: React.CSSProperties = { padding: '5px 9px', borderBottom: '1px solid #eee', fontSize: 13 }
export const th: React.CSSProperties = { ...cell, borderBottom: '2px solid #ccc', textAlign: 'left', background: '#fafafa' }
export type Row = Record<string, any>
/** 정식 pacs.008.001.08 전문 생성(테스트 실행용). */
export function buildPacs008(bmi: string, from: string, to: string, amt: number): string {
const now = new Date().toISOString().replace(/\.\d+Z$/, '')
return `<?xml version="1.0" encoding="UTF-8"?><Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.08">` +
`<FIToFICstmrCdtTrf><GrpHdr><MsgId>${bmi}</MsgId><CreDtTm>${now}</CreDtTm><NbOfTxs>1</NbOfTxs>` +
`<SttlmInf><SttlmMtd>CLRG</SttlmMtd></SttlmInf></GrpHdr><CdtTrfTxInf><PmtId><EndToEndId>${bmi}</EndToEndId></PmtId>` +
`<IntrBkSttlmAmt Ccy="KRW">${amt}</IntrBkSttlmAmt><ChrgBr>SLEV</ChrgBr>` +
`<Dbtr><Nm>BANK-${from}</Nm></Dbtr><DbtrAcct><Id><Othr><Id>ACC-${from}</Id></Othr></Id></DbtrAcct>` +
`<DbtrAgt><FinInstnId><ClrSysMmbId><MmbId>${from}</MmbId></ClrSysMmbId></FinInstnId></DbtrAgt>` +
`<CdtrAgt><FinInstnId><ClrSysMmbId><MmbId>${to}</MmbId></ClrSysMmbId></FinInstnId></CdtrAgt>` +
`<Cdtr><Nm>BANK-${to}</Nm></Cdtr><CdtrAcct><Id><Othr><Id>ACC-${to}</Id></Othr></Id></CdtrAcct>` +
`</CdtTrfTxInf></FIToFICstmrCdtTrf></Document>`
}
export function MiniTable({ title, cols, rows, timeCols = [] }: { title: string; cols: [string, string][]; rows: Row[]; timeCols?: string[] }) {
return (
<div style={{ marginTop: 10 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{title} <span style={{ color: '#888', fontSize: 12 }}>({rows.length})</span></div>
<div style={{ overflowX: 'auto' }}>
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
<thead><tr>{cols.map(([, ko]) => <th key={ko} style={th}>{ko}</th>)}</tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>{cols.map(([k]) => <td key={k} style={{ ...cell, fontFamily: /code|seq|bmi|balance|amount/.test(k) ? 'monospace' : undefined }}>
{timeCols.includes(k) ? fmtTs(r[k]) : String(r[k] ?? '-')}
</td>)}</tr>
))}
</tbody>
</table>
</div>
</div>
)
}