acquire-core-x/app/ui/index.html
hyeongwoo-choi b484d79845 fix(ui): 절대경로 → 상대경로 (프리뷰/프록시 환경에서 screens.js·API 404 수정)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 02:45:37 +00:00

265 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>ACQUIRE-CORE-X 매입업무포털</title>
<style>
/* Xplatform(UXStudio) 풍 내부 업무화면 스타일 */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; font-family: "Malgun Gothic", "맑은 고딕", Dotum, sans-serif; font-size: 12px; color: #222; }
body { display: flex; flex-direction: column; background: #e8ebef; }
#titlebar { height: 44px; background: linear-gradient(#2c5d8f, #1d4269); color: #fff; display: flex; align-items: center; padding: 0 14px; flex-shrink: 0; }
#titlebar .logo { font-size: 16px; font-weight: bold; letter-spacing: 1px; }
#titlebar .sub { margin-left: 12px; font-size: 11px; color: #bcd3e8; }
#titlebar .right { margin-left: auto; font-size: 11px; color: #d6e4f2; }
#titlebar .right b { color: #ffd27f; }
#main { flex: 1; display: flex; min-height: 0; }
/* 좌측 메뉴 트리 */
#menuwrap { width: 232px; background: #f4f6f8; border-right: 1px solid #b8c2cc; display: flex; flex-direction: column; }
#menusearch { padding: 6px; border-bottom: 1px solid #ccd4dc; }
#menusearch input { width: 100%; height: 24px; border: 1px solid #9aa8b5; padding: 0 8px; font-size: 12px; }
#menu { flex: 1; overflow-y: auto; }
#menu .grp { padding: 7px 10px; background: #dbe3ea; border-bottom: 1px solid #c3cdd6; font-weight: bold; color: #2c4a66; cursor: pointer; display: flex; }
#menu .grp .cnt { margin-left: auto; font-weight: normal; color: #7b8a99; font-size: 10px; }
#menu .grp::before { content: "▸ "; color: #7b8a99; }
#menu .grp.open::before { content: "▾ "; }
#menu .itm { padding: 5px 8px 5px 22px; border-bottom: 1px solid #e6eaee; cursor: pointer; color: #333;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: none; }
#menu .grp.open + .itms .itm, #menu .itms.show .itm { display: block; }
#menu .itm:hover { background: #e8f0f8; }
#menu .itm.on { background: #cfe0f0; color: #14487c; font-weight: bold; }
#menu .itm .cd { color: #888; font-size: 10px; margin-right: 5px; font-family: Consolas, monospace; }
#content { flex: 1; display: flex; flex-direction: column; min-width: 0; }
#tabbar { height: 30px; background: #d5dce3; border-bottom: 1px solid #a9b4bf; display: flex; align-items: flex-end; padding: 0 8px; flex-shrink: 0; }
#tabbar .tab { padding: 5px 16px 4px; background: #fff; border: 1px solid #a9b4bf; border-bottom: none; font-weight: bold; color: #1d4269; }
#work { flex: 1; overflow: auto; padding: 12px; }
.panel { background: #fff; border: 1px solid #b0bac4; margin-bottom: 12px; }
.panel h3 { font-size: 12px; padding: 6px 10px; background: #eef2f6; border-bottom: 1px solid #ccd4dc; color: #1d4269; display: flex; align-items: center; }
.panel h3::before { content: ""; width: 4px; height: 12px; background: #2c5d8f; margin-right: 6px; flex-shrink: 0; }
.panel .bd { padding: 10px; }
table.frm { border-collapse: collapse; width: 100%; }
table.frm th { background: #f0f3f6; border: 1px solid #d3dae1; padding: 5px 8px; text-align: right; width: 150px; font-weight: normal; color: #445; }
table.frm td { border: 1px solid #d3dae1; padding: 3px 6px; }
table.frm input[type=text] { width: 100%; max-width: 280px; height: 22px; border: 1px solid #9aa8b5; padding: 0 6px; font-size: 12px; }
table.frm .fldcd { color: #98a4b0; font-size: 10px; margin-left: 8px; font-family: Consolas, monospace; }
.btnrow { margin-top: 10px; text-align: right; }
button { height: 26px; padding: 0 16px; margin-left: 6px; background: linear-gradient(#fdfdfd, #dfe5ea); border: 1px solid #8fa0b0; cursor: pointer; font-size: 12px; }
button.pri { background: linear-gradient(#3f76ad, #29578a); color: #fff; border-color: #1d4269; font-weight: bold; }
button:hover { filter: brightness(1.06); }
table.grd { border-collapse: collapse; width: 100%; }
table.grd th { background: #dfe6ec; border: 1px solid #b8c2cc; padding: 4px 6px; color: #2c4a66; }
table.grd td { border: 1px solid #d3dae1; padding: 3px 6px; background: #fff; }
table.grd tr:nth-child(even) td { background: #f7fafc; }
.num { text-align: right; font-family: Consolas, monospace; }
.ok { color: #0a7d2c; font-weight: bold; }
.err { color: #c22; font-weight: bold; }
#statusbar { height: 24px; background: #d5dce3; border-top: 1px solid #a9b4bf; display: flex; align-items: center; padding: 0 10px; font-size: 11px; color: #345; flex-shrink: 0; }
#statusbar .dot { width: 8px; height: 8px; border-radius: 50%; background: #23a54a; margin-right: 6px; }
</style>
</head>
<body>
<div id="titlebar">
<span class="logo">ACQUIRE-CORE-X</span>
<span class="sub">글로벌 매입업무포털 (Enduro/X · ECPG/PostgreSQL · XA)</span>
<span class="right">사용자: <b>매입운영1팀</b> | 영업일 <span id="bizdate"></span> | 화면 <span id="scrno">-</span> | 전체화면 <b id="scrcnt">-</b></span>
</div>
<div id="main">
<div id="menuwrap">
<div id="menusearch"><input id="q" placeholder="화면/서비스 검색 (예: 매입, SETTLE)"></div>
<div id="menu"></div>
</div>
<div id="content">
<div id="tabbar"><span class="tab" id="tabname">-</span></div>
<div id="work"></div>
<div id="statusbar"><span class="dot"></span><span id="stmsg">TP 모니터 연결됨 — 게이트웨이 /api/call</span></div>
</div>
</div>
<script src="ui/screens.js"></script>
<script>
/* screens.js 로드 가드: 프록시/경로 문제로 실패 시 상대경로 재시도 후 에러 표기 */
if (typeof SVC_SCREENS === 'undefined') {
document.getElementById && (window.__needScreens = true);
}
</script>
<script>
/* ───────── 공통: 필드 한글 라벨 / 기본값 ───────── */
const today = new Date().toISOString().slice(0,10).replace(/-/g,'');
document.getElementById('bizdate').textContent = today;
const LBL = {
T_MERCHANT:"가맹점번호", T_AMOUNT:"금액(원)", T_FEE:"수수료", T_NET:"정산액", T_STATUS:"상태",
T_PURCHASE_ID:"매입번호", T_BIZDATE:"영업일자", T_SETTLE_ID:"정산번호", T_MSG:"메시지",
T_CANCEL_ID:"취소번호", T_INSTALL_N:"할부개월", T_TAX:"세액", T_FX_AMT:"외화금액", T_COUNT:"건수",
T_REASON:"사유", T_CATEGORY:"업종분류", T_ISSUER:"발급사", T_CHANNEL:"채널", T_CCY:"통화",
T_ADJ:"조정번호", T_DELTA:"증감액", T_SVCNAME:"서비스명", T_RC:"결과코드", T_GROSS:"총액", T_DOCNO:"문서번호",
T_KEY1:"키1(카드/계좌)", T_KEY2:"키2", T_ID1:"번호1", T_ID2:"번호2",
};
for (let i=1;i<=8;i++){ LBL['T_ARG'+i]='인자'+i; LBL['T_STR'+i]='문자'+i; }
for (let i=1;i<=4;i++){ LBL['T_AMT'+i]='금액'+i; }
const DEF = {
T_MERCHANT:"M0001", T_AMOUNT:"1000000", T_BIZDATE:today, T_PURCHASE_ID:"1", T_SETTLE_ID:"1",
T_CANCEL_ID:"1", T_ADJ:"1", T_ID1:"1", T_ID2:"1", T_INSTALL_N:"3", T_COUNT:"10",
T_CCY:"KRW", T_CHANNEL:"POS", T_ISSUER:"BC", T_CATEGORY:"5411", T_STATUS:"A",
T_KEY1:"4111111111111111", T_STR5:"4111111111111111", T_FEE:"2500", T_DOCNO:"D0001",
T_SVCNAME:"ACQUIRE", T_GROSS:"0", T_DELTA:"0", T_TAX:"0", T_FX_AMT:"0",
};
const dflt = f => DEF[f] !== undefined ? DEF[f] : (/T_(ARG|AMT)/.test(f) ? "0" : "");
/* XA 를 기본으로 켤 화면(갱신성) 판단: 조회성 키워드면 OFF */
const READONLY = /(_INQ|_CHK|STATUS|SUMMARY|STATS|HISTORY|REPORT|_SUM|BALCHK|LUHN|CRC|WEEKDAY|MONTHEND|BIZDAY|CODENAME|VALIDATE|PROGRESS|VERIFY|DIFFCHK|INQUIRY|TRIALBAL)/;
/* ───────── 즐겨찾기(주요 업무) ───────── */
const FAVS = [
{ svc:"ACQUIRE", nm:"매입 접수(정산체인)", note:"글로벌 XA: ACQUIRE→RECONCILE→SETTLE 체인 후 2PC 커밋" },
{ svc:"ACQ_STATUS", nm:"매입 상태조회" },
{ svc:"ACQ_MERCHSUM", nm:"가맹점 매입집계" },
{ svc:"LIMIT_INQ", nm:"한도 조회" },
{ svc:"ST_MDR", nm:"MDR 수수료 산출" },
{ svc:"LG_BALCHK", nm:"원장 잔액검증" },
{ svc:"CL_DAILY", nm:"일마감 처리" },
{ svc:"MM_MERCH_INQ", nm:"가맹점 조회" },
{ svc:"CM_LUHN", nm:"카드번호 검증(Luhn)" },
];
/* ───────── 메뉴 구성: 즐겨찾기 + 11개 모듈 × 30 화면 ───────── */
const byMod = {};
SVC_SCREENS.forEach(s => { (byMod[s.mod] = byMod[s.mod] || []).push(s); });
const bySvc = Object.fromEntries(SVC_SCREENS.map(s => [s.svc, s]));
document.getElementById('scrcnt').textContent = SVC_SCREENS.length;
const menuEl = document.getElementById('menu');
let CUR = null;
function scrCode(s){ const i = byMod[s.mod].indexOf(s); return s.mod.toUpperCase() + String(i+1).padStart(2,'0') + '01'; }
function addGroup(title, items, open) {
const gd = document.createElement('div'); gd.className = 'grp' + (open?' open':'');
gd.innerHTML = `${title}<span class="cnt">${items.length}</span>`;
const wrap = document.createElement('div'); wrap.className = 'itms';
gd.onclick = () => gd.classList.toggle('open');
menuEl.appendChild(gd); menuEl.appendChild(wrap);
items.forEach(({s, nm, note}) => {
const it = document.createElement('div'); it.className = 'itm';
const cd = scrCode(s);
it.innerHTML = `<span class="cd">${cd}</span>${nm || s.svc}`;
it.title = (s.desc || s.svc);
it.dataset.k = (cd + ' ' + s.svc + ' ' + (nm||'') + ' ' + (s.desc||'')).toLowerCase();
it.onclick = (e) => { e.stopPropagation(); openScreen(s, it, nm, note); };
wrap.appendChild(it);
if (s.svc === 'ACQUIRE' && open) setTimeout(()=>openScreen(s, it, nm, note), 0);
});
}
addGroup('★ 주요 업무', FAVS.filter(f=>bySvc[f.svc]).map(f=>({s:bySvc[f.svc], nm:f.nm, note:f.note})), true);
Object.keys(byMod).sort().forEach(m => {
addGroup(`${byMod[m][0].modnm} (${m}) 업무`, byMod[m].map(s=>({s})), false);
});
/* 검색 */
document.getElementById('q').addEventListener('input', e => {
const q = e.target.value.trim().toLowerCase();
document.querySelectorAll('#menu .itms').forEach(w => w.classList.toggle('show', !!q));
document.querySelectorAll('#menu .itm').forEach(it =>
it.style.display = (!q || it.dataset.k.includes(q)) ? (q ? 'block' : '') : 'none');
});
/* ───────── 화면 렌더 ───────── */
const work = document.getElementById('work');
const journal = [];
function openScreen(s, itEl, nm, note) {
CUR = { s, nm: nm || s.svc };
document.querySelectorAll('#menu .itm').forEach(e=>e.classList.remove('on'));
if (itEl) itEl.classList.add('on');
const cd = scrCode(s);
document.getElementById('tabname').textContent = `${nm || s.svc}`;
document.getElementById('scrno').textContent = cd;
const useTx = !READONLY.test(s.svc);
const rows = (s.in.length ? s.in : []).map((f,i) =>
`<tr><th>${LBL[f]||f}<span class="fldcd">${f}</span></th>
<td><input type="text" id="f_${i}" data-fld="${f}" value="${dflt(f)}"></td></tr>`).join('')
|| `<tr><th>입력</th><td style="color:#889">입력 필드 없음 — 즉시 실행</td></tr>`;
const outHint = s.out.length ? s.out.map(f=>LBL[f]||f).join(', ') : '-';
work.innerHTML = `
<div class="panel"><h3>${cd} · ${nm || s.svc} — <span style="font-weight:normal;color:#667">${s.desc || note || ''}</span></h3>
<div class="bd">
<table class="frm">
<tr><th>서비스명</th><td><b>${s.svc}</b> <span class="fldcd">(${s.modnm} 모듈 · ${s.mod}_svr)</span></td></tr>
${rows}
<tr><th>글로벌 XA</th><td><label><input type="checkbox" id="usetx" ${useTx?'checked':''}> tpbegin / tpcommit (2PC)</label></td></tr>
</table>
<div style="margin-top:6px;color:#889">응답 필드: ${outHint}</div>
<div class="btnrow">
<button onclick="resetForm()">초기화</button>
<button class="pri" onclick="execute()">실행 (tpcall)</button>
</div>
</div>
</div>
<div class="panel"><h3>처리 결과 (응답 전문 UBF)</h3>
<div class="bd"><table class="grd" id="resgrid"><tr><th style="width:200px">필드</th><th>값</th></tr>
<tr><td colspan="2" style="color:#999">실행 전</td></tr></table></div>
</div>
<div class="panel"><h3>거래 저널 (금일 호출이력)</h3>
<div class="bd"><table class="grd" id="jgrid">
<tr><th>시각</th><th>화면</th><th>서비스</th><th>XA</th><th>결과</th><th>요약</th></tr></table></div>
</div>`;
renderJournal();
}
function resetForm(){ const on = document.querySelector('#menu .itm.on'); openScreen(CUR.s, on, CUR.nm); }
async function execute() {
const { s } = CUR;
const useTx = document.getElementById('usetx').checked ? 1 : 0;
const p = new URLSearchParams({ svc: s.svc, _tx: useTx });
s.in.forEach((f,i) => {
const v = document.getElementById('f_'+i)?.value;
if (v !== undefined && v !== '') p.append(f, v);
});
setStatus(`tpcall("${s.svc}") 호출 중 ...`);
let j;
try { j = await (await fetch('api/call?' + p.toString())).json(); }
catch (e) { j = { ok:false, error: String(e) }; }
const g = document.getElementById('resgrid');
g.innerHTML = '<tr><th style="width:200px">필드</th><th>값</th></tr>';
if (j.ok) {
const f = j.fields || {};
if (!Object.keys(f).length) g.innerHTML += '<tr><td colspan="2">(응답 필드 없음 — 정상 처리)</td></tr>';
for (const [k,v] of Object.entries(f))
g.innerHTML += `<tr><td>${LBL[k.replace('#','')]||k} <span class="fldcd">${k}</span></td><td class="num">${fmt(k,v)}</td></tr>`;
setStatus(`tpcall("${s.svc}") 정상 처리${useTx?' · XA tpcommit 완료(2PC)':''}`);
} else {
g.innerHTML += `<tr><td>오류</td><td class="err">${j.error||'unknown'}</td></tr>`;
setStatus(`tpcall("${s.svc}") 실패`, true);
}
journal.unshift({ t:new Date().toLocaleTimeString('ko-KR'), cd:scrCode(s), svc:s.svc, tx:useTx?'Y':'N',
ok:j.ok, sum: j.ok ? summarize(j.fields) : (j.error||'').slice(0,70) });
renderJournal();
}
function fmt(k, v){ return /AMOUNT|FEE|NET|GROSS|AMT|TAX|DELTA/.test(k) && /^\-?\d+$/.test(v) ? Number(v).toLocaleString('ko-KR') : v; }
function summarize(f){ if(!f) return ''; return Object.entries(f).slice(0,4).map(([k,v])=>`${k}=${v}`).join(' '); }
function renderJournal(){
const g = document.getElementById('jgrid'); if (!g) return;
g.innerHTML = '<tr><th>시각</th><th>화면</th><th>서비스</th><th>XA</th><th>결과</th><th>요약</th></tr>' +
journal.map(r=>`<tr><td>${r.t}</td><td>${r.cd}</td><td>${r.svc}</td><td>${r.tx}</td>
<td class="${r.ok?'ok':'err'}">${r.ok?'정상':'오류'}</td><td>${r.sum}</td></tr>`).join('');
}
function setStatus(m, err){ const e=document.getElementById('stmsg'); e.textContent=m; e.style.color=err?'#c22':'#345'; }
</script>
</body>
</html>