실운영 데이터 + 운영 대시보드/조회 화면 (실사용 시스템화)
- db/schema.d/90-seed.sql: 가맹점 40(실제풍 상호·업종별 MDR 18~30bp·한도), 2026-04-20~07-19 3개월 매입 8,190건(주중/주말 거래량·금액구간 분포·채널/발급사 비중), 승인 1:1, 정산 7,560, 원장 15,120(RECON+SETTLE 이중기표), 가맹점 일집계, 시퀀스 정렬 - 게이트웨이 /api/query: libpq 읽기전용 조회(SELECT만, 단일문, 500행 캡) — MIS/리포팅 경로 - UI 조회/현황 그룹 7화면: 운영 대시보드(카드 7종+최근거래+가맹점TOP7+7영업일 실적), 매입/정산 목록, 가맹점 현황, 일자별 실적, 원장, 채널·발급사 분석 — 기본화면=대시보드 - 검증: 시드 로딩 psql 확인, /api/query 동작, 헤드리스 스크린샷으로 대시보드 실데이터 렌더 확인 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c5692410f6
commit
1fd60a1e49
4 changed files with 305 additions and 2 deletions
|
|
@ -63,6 +63,10 @@
|
|||
|
||||
#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; }
|
||||
.cards { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.card { flex: 1; min-width: 128px; border: 1px solid #c6d0da; background: #f7fafd; padding: 8px 10px; }
|
||||
.card .cl { font-size: 11px; color: #667; }
|
||||
.card .cv { font-size: 16px; font-weight: bold; color: #1d4269; margin-top: 4px; text-align: right; font-family: Consolas, monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -4461,14 +4465,106 @@ function addGroup(title, items, open) {
|
|||
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/* ───────── 조회/현황 화면 (운영 데이터 그리드 — /api/query) ───────── */
|
||||
let CURQ = null;
|
||||
const fmtN = v => (v===null||v===undefined||v==='') ? '' : (/^-?\d+$/.test(String(v)) ? Number(v).toLocaleString('ko-KR') : v);
|
||||
async function runQuery(sql){
|
||||
try { return await (await fetch('api/query?q='+encodeURIComponent(sql))).json(); }
|
||||
catch(e){ return {ok:false, error:String(e)}; }
|
||||
}
|
||||
const QSCREENS = [
|
||||
{cd:'HM0001', nm:'운영 대시보드', dash:true},
|
||||
{cd:'QR0101', nm:'매입 목록 조회', filters:[['from','시작일','2026-07-01'],['to','종료일','2026-07-19'],['merch','가맹점번호(선택)','']],
|
||||
sql:f=>`select p.purchase_id 매입번호, p.biz_date 영업일, p.merchant_id 가맹점, m.name 상호, p.amount 매입금액, p.fee 수수료, p.net 정산액, p.status 상태, p.channel 채널, p.issuer 발급사 from purchase p join merchant m on m.merchant_id=p.merchant_id where p.biz_date between '${f.from}' and '${f.to}' ${f.merch?`and p.merchant_id='${f.merch}'`:''} order by p.purchase_id desc limit 200`},
|
||||
{cd:'QR0201', nm:'정산 목록 조회', filters:[['from','시작일','2026-07-01'],['to','종료일','2026-07-19']],
|
||||
sql:f=>`select s.settlement_id 정산번호, s.biz_date 영업일, s.merchant_id 가맹점, m.name 상호, s.net 지급액, s.status 상태, to_char(s.settled_at,'MM-DD HH24:MI') 지급시각 from settlement s join merchant m on m.merchant_id=s.merchant_id where s.biz_date between '${f.from}' and '${f.to}' order by s.settlement_id desc limit 200`},
|
||||
{cd:'QR0301', nm:'가맹점 현황', filters:[],
|
||||
sql:f=>`select m.merchant_id 가맹점번호, m.name 상호, m.mdr_bps "MDR(bp)", m.daily_limit 일한도, m.status 상태, coalesce(t.cnt,0) 누적건수, coalesce(t.amt,0) 누적매입액 from merchant m left join (select merchant_id, count(*) cnt, sum(amount) amt from purchase group by merchant_id) t on t.merchant_id=m.merchant_id order by m.merchant_id limit 200`},
|
||||
{cd:'QR0401', nm:'일자별 실적 집계', filters:[['from','시작일','2026-06-20'],['to','종료일','2026-07-19']],
|
||||
sql:f=>`select biz_date 영업일, count(*) 건수, sum(amount) 매입금액, sum(fee) 수수료, sum(net) 정산액, count(*) filter (where status='S') 정산완료, count(*) filter (where status in ('A','M')) 진행중 from purchase where biz_date between '${f.from}' and '${f.to}' group by biz_date order by biz_date desc limit 200`},
|
||||
{cd:'QR0501', nm:'원장 조회', filters:[['from','시작일','2026-07-10'],['to','종료일','2026-07-19']],
|
||||
sql:f=>`select l.ledger_id 전표번호, l.biz_date 영업일, l.entry_type 유형, l.purchase_id 매입번호, l.amount 금액 from ledger l where l.biz_date between '${f.from}' and '${f.to}' order by l.ledger_id desc limit 200`},
|
||||
{cd:'QR0601', nm:'채널·발급사 분석', filters:[['from','시작일','2026-07-01'],['to','종료일','2026-07-19']],
|
||||
sql:f=>`select channel 채널, issuer 발급사, count(*) 건수, sum(amount) 매입금액, round(avg(amount)) 평균금액 from purchase where biz_date between '${f.from}' and '${f.to}' group by channel, issuer order by 4 desc limit 200`},
|
||||
];
|
||||
function gridHtml(j){
|
||||
if (!j.rows.length) return '<span style="color:#889">데이터 없음</span>';
|
||||
return '<table class="grd"><tr>' + j.cols.map(c=>`<th>${c}</th>`).join('') + '</tr>' +
|
||||
j.rows.map(r=>'<tr>'+r.map(v=>`<td class="${/^-?\d+$/.test(v)?'num':''}">${fmtN(v)}</td>`).join('')+'</tr>').join('') + '</table>';
|
||||
}
|
||||
async function openQuery(q, itEl){
|
||||
CUR = null; CURQ = q;
|
||||
document.querySelectorAll('#menu .itm').forEach(e=>e.classList.remove('on'));
|
||||
if (itEl) itEl.classList.add('on');
|
||||
document.getElementById('tabname').textContent = q.nm;
|
||||
document.getElementById('scrno').textContent = q.cd;
|
||||
if (q.dash) return renderDash();
|
||||
const f = (q.filters||[]).map(([id,label,dv]) =>
|
||||
`<tr><th>${label}</th><td><input type="text" id="qf_${id}" value="${dv}"></td></tr>`).join('');
|
||||
work.innerHTML = `
|
||||
<div class="panel"><h3>${q.cd} · ${q.nm}</h3><div class="bd">
|
||||
<table class="frm">${f||'<tr><th>조건</th><td style="color:#889">전체 조회</td></tr>'}</table>
|
||||
<div class="btnrow"><button class="pri" onclick="runQ()">조회</button></div>
|
||||
</div></div>
|
||||
<div class="panel"><h3>조회 결과</h3><div class="bd" id="qgrid" style="overflow:auto">조회 중 ...</div></div>`;
|
||||
runQ();
|
||||
}
|
||||
async function runQ(){
|
||||
const q = CURQ, f = {};
|
||||
(q.filters||[]).forEach(([id]) => { f[id] = (document.getElementById('qf_'+id)||{}).value || ''; });
|
||||
setStatus('조회 중 ...');
|
||||
const j = await runQuery(q.sql(f));
|
||||
const g = document.getElementById('qgrid');
|
||||
if (!j.ok) { g.innerHTML = `<span class="err">${j.error}</span>`; setStatus('조회 실패', true); return; }
|
||||
g.innerHTML = gridHtml(j);
|
||||
setStatus(`조회 완료 — ${j.rows.length}건`);
|
||||
}
|
||||
async function renderDash(){
|
||||
work.innerHTML = `
|
||||
<div class="panel"><h3>HM0001 · 운영 대시보드 — 글로벌 매입 현황</h3><div class="bd"><div class="cards" id="cards">로딩...</div></div></div>
|
||||
<div class="panel"><h3>최근 매입 거래</h3><div class="bd" id="d_recent" style="overflow:auto">로딩...</div></div>
|
||||
<div style="display:flex; gap:12px">
|
||||
<div class="panel" style="flex:1"><h3>당월 가맹점 TOP 7</h3><div class="bd" id="d_top">로딩...</div></div>
|
||||
<div class="panel" style="flex:1"><h3>최근 7영업일 실적</h3><div class="bd" id="d_daily">로딩...</div></div>
|
||||
</div>`;
|
||||
const k = await runQuery(`select to_char((select max(biz_date) from purchase),'YYYY-MM-DD') 기준일,(select count(*) from purchase where biz_date=(select max(biz_date) from purchase)) 오늘건수,(select coalesce(sum(amount),0) from purchase where biz_date=(select max(biz_date) from purchase)) 오늘매입액,(select count(*) from purchase where date_trunc('month',biz_date)=date_trunc('month',(select max(biz_date) from purchase))) 당월건수,(select coalesce(sum(amount),0) from purchase where date_trunc('month',biz_date)=date_trunc('month',(select max(biz_date) from purchase))) 당월매입액,(select count(*) from merchant where status='ACTIVE') 활성가맹점,(select count(*) from purchase where status in ('A','M','H')) 미정산건`);
|
||||
if (k.ok) {
|
||||
document.getElementById('cards').innerHTML =
|
||||
k.cols.map((n,i)=>`<div class="card"><div class="cl">${n}</div><div class="cv">${fmtN(k.rows[0][i])}</div></div>`).join('');
|
||||
} else document.getElementById('cards').innerHTML = `<span class="err">${k.error}</span>`;
|
||||
const r = await runQuery(`select p.purchase_id 매입번호, p.biz_date 영업일, m.name 가맹점, p.amount 금액, p.fee 수수료, p.status 상태, p.channel 채널, p.issuer 발급사 from purchase p join merchant m on m.merchant_id=p.merchant_id order by p.purchase_id desc limit 12`);
|
||||
if (r.ok) document.getElementById('d_recent').innerHTML = gridHtml(r);
|
||||
const t = await runQuery(`select m.name 가맹점, count(*) 건수, sum(p.amount) 매입금액 from purchase p join merchant m on m.merchant_id=p.merchant_id where date_trunc('month',p.biz_date)=date_trunc('month',(select max(biz_date) from purchase)) group by m.name order by 3 desc limit 7`);
|
||||
if (t.ok) document.getElementById('d_top').innerHTML = gridHtml(t);
|
||||
const d = await runQuery(`select biz_date 영업일, count(*) 건수, sum(amount) 매입금액, sum(net) 정산액 from purchase group by biz_date order by biz_date desc limit 7`);
|
||||
if (d.ok) document.getElementById('d_daily').innerHTML = gridHtml(d);
|
||||
setStatus('대시보드 로드 완료 — 운영 데이터 기준');
|
||||
}
|
||||
function addQueryGroup(){
|
||||
const gd = document.createElement('div'); gd.className = 'grp open';
|
||||
gd.innerHTML = `조회/현황 (운영)<span class="cnt">${QSCREENS.length}</span>`;
|
||||
const wrap = document.createElement('div'); wrap.className = 'itms';
|
||||
gd.onclick = () => gd.classList.toggle('open');
|
||||
menuEl.appendChild(gd); menuEl.appendChild(wrap);
|
||||
QSCREENS.forEach(q => {
|
||||
const it = document.createElement('div'); it.className = 'itm';
|
||||
it.innerHTML = `<span class="cd">${q.cd}</span>${q.nm}`;
|
||||
it.dataset.k = (q.cd+' '+q.nm).toLowerCase();
|
||||
it.onclick = e => { e.stopPropagation(); openQuery(q, it); };
|
||||
wrap.appendChild(it);
|
||||
});
|
||||
}
|
||||
addQueryGroup();
|
||||
|
||||
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);
|
||||
});
|
||||
setTimeout(()=>openQuery(QSCREENS[0], document.querySelector('#menu .itms .itm')), 0);
|
||||
|
||||
/* 검색 */
|
||||
document.getElementById('q').addEventListener('input', e => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue