Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
130 lines
11 KiB
JavaScript
130 lines
11 KiB
JavaScript
'use strict';
|
|
/* ============================================================================
|
|
* 캐시 · 충전 화면 (v1.6.40, 요구 1)
|
|
*
|
|
* 비용은 지금처럼 선불 적립 캐시(원 단위, lib/credits.js)에서 차감된다. 이 화면은
|
|
* ① 내 캐시: 잔액(크게) · 누적 사용 · 누적 지급 · 캐시 충전을 기다리는 작업 · "상세 내역"(app.js showUsageDetail — 일/주/월 사용량·전체 원장)
|
|
* ② 선불 충전: 관리자 콘솔에서 만든 충전 상품(금액 + 인센티브)을 고르면 결제 금액·인센티브·적립 예정 캐시·충전 뒤 예상 잔액을 보여 주고
|
|
* "결제 진행" 으로 주문을 접수한다. **PG(결제 대행)가 아직 연동되지 않아 실제 결제는 일어나지 않는다** — 주문은 "결제 대기" 로 남고
|
|
* 운영팀이 입금(결제)을 확인하면 적립된다(관리자 콘솔 › 회원·청구 › 선불 충전 주문). 이 사실을 화면에 그대로 적어 둔다.
|
|
* ③ 내 충전 주문: 상태(결제 대기 · 적립 완료 · 취소)와 결제 대기 주문의 취소.
|
|
* API: GET /api/credits/packages · GET/POST /api/credits/orders · POST /api/credits/orders/<id>/cancel · GET /api/me/credit
|
|
* 전역 의존(app.js): $, api, T, TF, krwFmt, config, showUsageDetail, uiLocale, loadLedger
|
|
* ========================================================================== */
|
|
(() => {
|
|
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = text; return e; };
|
|
const btn = (text, cls, onclick) => { const b = el('button', cls || 'ghost small-btn', text); b.type = 'button'; b.onclick = onclick; return b; };
|
|
const dtm = (s) => (s ? new Date(s).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-');
|
|
const STATUS = { pending: '결제 대기', paid: '적립 완료', cancelled: '취소' };
|
|
let packages = null; // GET /api/credits/packages 응답의 packages
|
|
let orders = null; // GET /api/credits/orders 응답의 orders (최신순)
|
|
let info = null; // GET /api/me/credit (잔액·누적·대기 작업)
|
|
let sel = null; // 고른 상품 id
|
|
let lastOrder = null; // 방금 접수한 주문 (접수 안내 상자)
|
|
let busy = false;
|
|
|
|
const visible = () => { const p = $('pane-credits'); return p && !p.classList.contains('hidden'); };
|
|
|
|
async function load() {
|
|
const box = $('creditsBody'); if (!box) return;
|
|
if (!packages) { box.innerHTML = ''; box.appendChild(el('p', 'muted small', '불러오는 중…')); }
|
|
try {
|
|
const [pk, od, cr] = await Promise.all([api('/api/credits/packages'), api('/api/credits/orders'), api('/api/me/credit')]);
|
|
packages = pk.packages || []; orders = od.orders || []; info = cr;
|
|
if (sel && !packages.some((p) => p.id === sel)) sel = null;
|
|
} catch (e) { box.innerHTML = ''; box.appendChild(el('p', 'muted small', '캐시 정보를 불러오지 못했습니다: ' + e.message)); return; }
|
|
render();
|
|
}
|
|
/** app.js renderCredit 가 잔액을 바꿀 때마다 호출 — 화면이 보이면 잔액 숫자만 갱신 */
|
|
function onCredit(i) { if (!i) return; info = { ...(info || {}), ...i }; const b = $('crBalance'); if (b) { b.textContent = krwFmt(info.balance); b.classList.toggle('neg', info.balance <= 0); } }
|
|
|
|
/** 인센티브 표시 문구: 정률 "10% 추가" · 정액 "+1,000원 추가" */
|
|
const bonusText = (p) => (!p.bonus ? '' : (p.bonusType === 'percent' ? TF('{1}% 추가 적립', p.bonusValue) : TF('{1} 추가 적립', krwFmt(p.bonusValue))));
|
|
|
|
function render() {
|
|
const box = $('creditsBody'); if (!box) return; box.innerHTML = '';
|
|
const isAdmin = config && config.user && config.user.role === 'admin';
|
|
// ① 내 캐시
|
|
{
|
|
const c = el('section', 'card'); c.appendChild(el('h2', null, '내 캐시'));
|
|
const hero = el('div', 'cr-hero'); const left = el('div');
|
|
const bal = el('div', 'cr-balance' + (info && info.balance <= 0 ? ' neg' : '')); bal.id = 'crBalance'; bal.textContent = info ? krwFmt(info.balance) : '-'; left.appendChild(bal);
|
|
left.appendChild(el('div', 'cr-sub', info ? `${T('누적 사용')} ${krwFmt(info.used)} · ${T('누적 지급')} ${krwFmt(info.granted)}${isAdmin ? ' · ' + T('관리자 계정은 캐시 한도를 받지 않습니다') : ''}` : ''));
|
|
hero.appendChild(left);
|
|
const act = el('div', 'fs-act'); act.appendChild(btn('상세 내역', 'ghost small-btn', () => { if (window.showUsageDetail) showUsageDetail(); })); act.appendChild(btn('새로고침', 'ghost small-btn', () => load())); hero.appendChild(act);
|
|
c.appendChild(hero);
|
|
if (info && info.balance <= 0 && !isAdmin) c.appendChild(el('p', 'small cr-notice', '캐시가 없어 새 AI 작업을 시작할 수 없습니다. 아래에서 충전 상품을 골라 신청하세요.'));
|
|
const waiting = (info && info.waiting) || [];
|
|
if (waiting.length) { const w = el('div', 'small'); w.appendChild(el('div', 'field-label', TF('캐시 충전을 기다리는 작업 {1}건 — 충전되면 자동으로 이어서 진행됩니다', waiting.length))); const ul = el('ul', 'git-list'); for (const x of waiting) { const li = el('li'); li.appendChild(el('span', 'kind', '대기')); li.appendChild(el('span', 'bname', `${x.title} — ${(x.instruction || '').slice(0, 60)}`)); li.appendChild(el('span', 'st', dtm(x.at))); ul.appendChild(li); } w.appendChild(ul); c.appendChild(w); }
|
|
box.appendChild(c);
|
|
}
|
|
// ② 선불 충전
|
|
{
|
|
const c = el('section', 'card'); c.appendChild(el('h2', null, '선불 충전'));
|
|
if (lastOrder) {
|
|
const d = el('div', 'cr-done');
|
|
d.appendChild(el('div', null, `✓ ${TF('주문 #{1} 을 접수했습니다', lastOrder.id)} — ${lastOrder.name} · ${T('결제 금액')} ${krwFmt(lastOrder.amount)} → ${T('적립 예정')} ${krwFmt(lastOrder.credit)}`));
|
|
d.appendChild(el('div', 'muted small', '결제 수단(PG)이 아직 연동되지 않아 실제 결제는 진행되지 않았습니다. 운영팀이 입금(결제)을 확인하면 적립 예정 캐시가 잔액에 더해집니다. 상태는 아래 "내 충전 주문" 에서 볼 수 있습니다.'));
|
|
const r = el('div', 'row'); r.appendChild(btn('다른 상품 고르기', 'ghost small-btn', () => { lastOrder = null; render(); })); d.appendChild(r);
|
|
c.appendChild(d); box.appendChild(c);
|
|
} else {
|
|
c.appendChild(el('p', 'muted small', '상품을 고르면 결제 금액과 적립될 캐시(인센티브 포함)를 확인할 수 있습니다. 적립 캐시 = 결제 금액 + 관리자가 정한 추가 인센티브.'));
|
|
if (!packages.length) c.appendChild(el('p', 'small cr-notice', '아직 판매 중인 충전 상품이 없습니다. 운영팀(관리자)에게 캐시 충전을 요청하세요.'));
|
|
else {
|
|
const grid = el('div', 'pk-grid');
|
|
for (const p of packages) {
|
|
const b = el('button', 'pk' + (sel === p.id ? ' on' : '')); b.type = 'button';
|
|
b.appendChild(el('span', 'pk-name', p.name)); b.appendChild(el('span', 'pk-amt', krwFmt(p.amount)));
|
|
if (p.bonus) b.appendChild(el('span', 'pk-bonus', bonusText(p)));
|
|
b.appendChild(el('span', 'pk-credit', TF('적립 {1}', krwFmt(p.credit))));
|
|
if (p.note) b.appendChild(el('span', 'muted small', p.note));
|
|
b.onclick = () => { sel = p.id; render(); };
|
|
grid.appendChild(b);
|
|
}
|
|
c.appendChild(grid);
|
|
const p = packages.find((x) => x.id === sel);
|
|
if (p) {
|
|
const sm = el('div', 'cr-summary');
|
|
const row = (k, v, cls) => { const r = el('div', 'cs-row' + (cls ? ' ' + cls : '')); r.appendChild(el('span', null, T(k))); r.appendChild(el('span', null, v)); sm.appendChild(r); };
|
|
row('결제 금액', krwFmt(p.amount)); row('추가 인센티브', p.bonus ? `+${krwFmt(p.bonus)}${p.bonusType === 'percent' ? ` (${p.bonusValue}%)` : ''}` : T('없음'));
|
|
row('적립 예정 캐시', krwFmt(p.credit), 'total'); if (info) row('충전 뒤 예상 잔액', krwFmt((info.balance || 0) + p.credit));
|
|
c.appendChild(sm);
|
|
c.appendChild(el('p', 'cr-notice', '결제 수단(PG) 연동 전입니다 — "결제 진행" 을 누르면 주문만 접수되고 실제 결제는 일어나지 않습니다. 운영팀이 입금(결제)을 확인하면 적립 예정 캐시가 잔액에 더해집니다.'));
|
|
const r = el('div', 'row'); const msg = el('span', 'muted small');
|
|
const goBtn = btn(TF('결제 진행 — {1}', krwFmt(p.amount)), 'primary small-btn', async () => {
|
|
if (busy) return; if (!confirm(TF('{1} ({2}) 충전 주문을 접수할까요?\n결제 수단 연동 전이라 지금은 결제가 진행되지 않고 주문만 남습니다.', p.name, krwFmt(p.amount)))) return;
|
|
busy = true; goBtn.disabled = true; msg.textContent = T('접수 중…');
|
|
try { const r2 = await api('/api/credits/orders', { method: 'POST', body: JSON.stringify({ packageId: p.id }) }); lastOrder = r2.order; orders = r2.orders || orders; sel = null; render(); }
|
|
catch (e) { msg.textContent = e.message; msg.style.color = '#b91c1c'; goBtn.disabled = false; }
|
|
busy = false;
|
|
});
|
|
r.appendChild(goBtn); r.appendChild(msg); c.appendChild(r);
|
|
}
|
|
}
|
|
box.appendChild(c);
|
|
}
|
|
}
|
|
// ③ 내 충전 주문
|
|
{
|
|
const c = el('section', 'card'); c.appendChild(el('h2', null, '내 충전 주문'));
|
|
if (!orders || !orders.length) c.appendChild(el('p', 'muted small', '아직 충전 주문이 없습니다.'));
|
|
else {
|
|
const list = el('div', 'order-list');
|
|
for (const o of orders) {
|
|
const row = el('div', 'ol-row');
|
|
const left = el('div'); const head = el('div'); head.appendChild(el('b', null, o.name)); head.appendChild(document.createTextNode(` · ${krwFmt(o.amount)} → ${T('적립')} ${krwFmt(o.credit)}`)); left.appendChild(head);
|
|
left.appendChild(el('div', 'ol-meta', `${TF('주문 #{1}', o.id)} · ${dtm(o.at)}${o.status === 'paid' ? ` · ${T('적립')} ${dtm(o.paidAt)}` : ''}${o.status === 'cancelled' && o.cancelledAt ? ` · ${T('취소')} ${dtm(o.cancelledAt)}` : ''}`));
|
|
row.appendChild(left);
|
|
const act = el('div', 'ol-act'); act.appendChild(el('span', 'pill small ' + o.status, T(STATUS[o.status] || o.status)));
|
|
if (o.status === 'pending') act.appendChild(btn('취소', 'ghost small-btn', async () => { if (!confirm(TF('주문 #{1} 을 취소할까요?', o.id))) return; try { const r = await api(`/api/credits/orders/${o.id}/cancel`, { method: 'POST', body: '{}' }); orders = r.orders || orders; if (lastOrder && lastOrder.id === o.id) lastOrder = null; render(); } catch (e) { alert(e.message); } }));
|
|
row.appendChild(act); list.appendChild(row);
|
|
}
|
|
c.appendChild(list);
|
|
}
|
|
box.appendChild(c);
|
|
}
|
|
}
|
|
|
|
window.CreditsUI = { load, onCredit, visible };
|
|
})();
|