상품·주문·수동 결제 승인·권리·청구·축소·해지·AI Wallet 표시·사용량 배정 화면·경고 엔진 (§6·§13·§27)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c90ac27596
commit
c397452acd
@@ -0,0 +1,19 @@
|
||||
/* portal/usage/public/allocate.js — 서비스별 보장량 배정 변경(§13.3). 구매 총량·사용 중·다른 서비스 배정·예약·미배정·변경 후 값을 함께 보인다. */
|
||||
(() => {
|
||||
const el = document.getElementById('alloc'); const subject = el.dataset.subject; const esc = aw.esc;
|
||||
const fmt = (n, unit) => (unit.startsWith('bytes') ? (n >= 1024 ** 3 ? (n / 1024 ** 3).toFixed(2) + ' GB' : n >= 1024 ** 2 ? (n / 1024 ** 2).toFixed(0) + ' MB' : n + ' B') : Number(n).toLocaleString('ko-KR'));
|
||||
const GB = 1024 ** 3;
|
||||
async function load() {
|
||||
try {
|
||||
const d = await aw.api('GET', `/api/usage/allocate/${encodeURIComponent(subject)}`);
|
||||
el.innerHTML = `<div class="card"><h2>${esc(d.subject.label)} · ${esc(d.subject.name)}</h2><p class="sub">월간 항목은 ${esc(d.nextResetText)} 에 초기화됩니다. 실제 사용량보다 작게 줄일 수 없고, 월간 항목은 이번 달 누적보다 작게 줄일 수 없습니다.</p></div>` +
|
||||
d.resources.map((r) => { const bytes = r.unit.startsWith('bytes'); const step = bytes ? GB : 1; return `<div class="card" data-res="${r.resource}"><h2>${esc(r.label)}${r.monthly ? ' (월간)' : ''}</h2>
|
||||
<dl class="kv"><dt>구매 총량</dt><dd>${esc(r.texts.purchased)}</dd><dt>이 서비스 사용 중(배정)</dt><dd>${esc(r.texts.mine)} <span class="badge neutral">○ ${esc(r.mineState)}</span></dd><dt>다른 서비스 배정</dt><dd>${esc(r.texts.others)}</dd><dt>진행 중 작업 예약</dt><dd>${esc(r.texts.reserved)}</dd><dt>미배정</dt><dd>${esc(r.texts.unallocated)}</dd><dt>실제 사용${r.monthly ? '(이번 달 누적)' : ''}</dt><dd>${esc(r.texts.used)}${r.measuredAt ? ` <span class="sub">계측 ${aw.fmt(r.measuredAt)}</span>` : ''}${r.stale ? ' <span class="badge warning">! 정보 확인 지연 — 늘릴 수 없음</span>' : ''}</dd>${r.limit ? `<dt>제한</dt><dd class="err">${r.limit.limited ? '100% 제한 적용 중' : r.limit.level + '% 경고'}</dd>` : ''}</dl>
|
||||
<form class="f" data-dirty-guard><label for="in-${r.resource}">변경 후 값 (${bytes ? 'GB' : '개'})</label><input id="in-${r.resource}" type="number" min="${Math.ceil(r.floor / step)}" max="${Math.floor(r.max / step)}" step="${bytes ? '0.5' : '1'}" value="${bytes ? (r.mine / GB).toFixed(1) : r.mine}"><p class="hint">허용 범위 ${fmt(r.floor, r.unit)} ~ ${fmt(r.max, r.unit)} (미배정 ${esc(r.texts.unallocated)} 안에서)</p><p class="err" id="err-${r.resource}"></p><div class="actions"><button class="btn primary" type="submit">적용</button></div></form></div>`; }).join('');
|
||||
el.querySelectorAll('form').forEach((f) => { f.onsubmit = async (ev) => { ev.preventDefault(); const res = f.closest('[data-res]').dataset.res; const r = d.resources.find((x) => x.resource === res); const v = Number(f.querySelector('input').value); const amount = r.unit.startsWith('bytes') ? Math.round(v * GB) : Math.round(v); const errEl = f.querySelector('.err'); errEl.textContent = '';
|
||||
try { const out = await aw.api('POST', `/api/usage/allocate/${encodeURIComponent(subject)}`, { resource: res, amount }); aw.markClean(); aw.toast(`${r.label} 배정을 ${fmt(out.before, r.unit)} → ${fmt(out.after, r.unit)} 로 바꿨습니다`); load(); }
|
||||
catch (e) { errEl.textContent = e.message + (e.detail?.cleanupNeeded ? ` (정리 필요: ${fmt(e.detail.cleanupNeeded, r.unit)})` : e.detail?.shortage ? ` (부족: ${fmt(e.detail.shortage, r.unit)})` : ''); } }; });
|
||||
} catch (e) { aw.state(el, e.status === 403 ? 'forbidden' : e.status === 404 ? 'empty' : 'failed', e.message, { retry: load }); }
|
||||
}
|
||||
load();
|
||||
})();
|
||||
@@ -0,0 +1,21 @@
|
||||
/* portal/usage/public/usage.js — 사용량 화면(§13.2·§13.4). /api/usage 를 그려 준다. */
|
||||
(() => {
|
||||
const el = document.getElementById('usage'); const esc = aw.esc;
|
||||
const bar = (pct, lvl) => `<div class="bar w${lvl || 0}" aria-label="${pct}%"><i style="width:${Math.min(100, pct)}%"></i></div>`;
|
||||
const lvlOf = (p) => (p >= 100 ? 100 : p >= 95 ? 95 : p >= 90 ? 90 : p >= 80 ? 80 : p >= 70 ? 70 : 0);
|
||||
async function load() {
|
||||
try {
|
||||
const d = await aw.api('GET', '/api/usage');
|
||||
if (!d.product) { aw.state(el, 'empty', '활성 상품이 없습니다. 상품을 주문하면 사용량이 표시됩니다', { href: '/products', hrefLabel: '상품 보기' }); return; }
|
||||
let h = `<div class="card"><dl class="kv"><dt>적용 상품정책</dt><dd>${esc(d.product.name)} v${d.product.version} (경고 구간 ${d.product.warnLevels.join('·')}%)</dd><dt>다음 KST 월 초기화</dt><dd>${esc(d.nextResetText)}</dd><dt>마지막 계측</dt><dd>${esc(d.lastMeasuredText)}${d.stale ? ' <span class="badge warning">! 정보 확인 지연</span>' : ''}</dd></dl></div>`;
|
||||
if (d.limits.length) h += `<div class="card"><h2>경고·제한</h2><ul>${d.limits.map((l) => `<li><span class="badge ${l.limited ? 'failed' : 'warning'}">${l.limited ? '✘ 제한' : '! ' + l.level + '%'}</span> ${esc(l.resource)} · ${esc(l.subject_type)} ${esc(l.subject_id)}${l.limited && l.semantics ? ` — 제한: ${esc(l.semantics.blocked.join(', '))} / 유지: ${esc(l.semantics.kept.join(', '))} / 해제: ${esc(l.semantics.recovery)}` : ''}${l.overage ? ' (초과 사용 허용 중)' : ''}</li>`).join('')}</ul></div>`;
|
||||
h += `<div class="card"><h2>그룹 전체(구매 총량 기준)</h2><table class="r"><thead><tr><th>자원</th><th>구매 총량</th><th>배정</th><th>예약(진행 중 작업)</th><th>미배정</th><th>실제 사용</th><th>사용률</th></tr></thead><tbody>${d.resources.map((r) => `<tr><td data-l="자원">${esc(r.label)}${r.monthly ? ' <span class="sub">(월간)</span>' : ''}</td><td data-l="구매">${fmt(r.purchased, r.unit)}</td><td data-l="배정">${fmt(r.allocated, r.unit)}</td><td data-l="예약">${fmt(r.reserved, r.unit)}</td><td data-l="미배정">${fmt(r.unallocated, r.unit)}</td><td data-l="사용">${fmt(r.used, r.unit)}</td><td data-l="사용률">${bar(r.usedPercent, lvlOf(r.usedPercent))} ${r.usedPercent}%</td></tr>`).join('')}</tbody></table></div>`;
|
||||
h += `<div class="card"><h2>서비스별 배정과 사용</h2>${d.allocations.length ? `<table class="r"><thead><tr><th>서비스</th><th>자원</th><th>배정</th><th>상태</th><th>사용</th><th>계측</th><th></th></tr></thead><tbody>${d.allocations.map((a) => `<tr><td data-l="서비스">${esc(a.subjectLabel)} · ${esc(a.subjectName)}</td><td data-l="자원">${esc(a.label)}</td><td data-l="배정">${esc(a.amountText)}</td><td data-l="상태"><span class="badge neutral">○ ${esc(a.stateLabel)}</span></td><td data-l="사용">${esc(a.usedText)} ${a.percent != null ? bar(a.percent, lvlOf(a.percent)) + a.percent + '%' : ''}</td><td data-l="계측">${a.measured_at ? aw.fmt(a.measured_at) : '계측 전'}${a.stale ? ' <span class="badge warning">! 지연</span>' : ''}</td><td data-l=""><a class="btn" href="/usage/allocate/${esc(a.subject_type)}:${esc(a.subject_id)}">배정 변경</a></td></tr>`).join('')}</tbody></table>` : '<p class="muted">아직 배정된 서비스가 없습니다. 사이트나 메일 서비스를 만들면 여기에 표시됩니다.</p>'}</div>`;
|
||||
if (d.reservations.length) h += `<div class="card"><h2>진행 중 작업의 예약</h2><table class="r"><thead><tr><th>자원</th><th>양</th><th>상태</th><th>사유</th><th>만료·반환 예정</th></tr></thead><tbody>${d.reservations.map((r) => `<tr><td data-l="자원">${esc(r.label)}</td><td data-l="양">${r.amount}</td><td data-l="상태">${esc(r.stateLabel)}</td><td data-l="사유">${esc(r.reason || '')}</td><td data-l="예정">${r.return_due_at ? aw.fmt(r.return_due_at) : r.expires_at ? aw.fmt(r.expires_at) : ''}</td></tr>`).join('')}</tbody></table></div>`;
|
||||
el.innerHTML = h;
|
||||
} catch (e) { aw.state(el, e.status === 403 ? 'forbidden' : 'failed', e.message, { retry: load }); }
|
||||
}
|
||||
function fmt(n, unit) { if (unit.startsWith('bytes')) return n >= 1024 ** 3 ? (n / 1024 ** 3).toFixed(1) + ' GB' : n >= 1024 ** 2 ? (n / 1024 ** 2).toFixed(0) + ' MB' : n + ' B'; return Number(n).toLocaleString('ko-KR'); }
|
||||
document.addEventListener('click', async (e) => { if (e.target.closest('[data-action=evaluate]')) { try { const r = await aw.api('POST', '/api/usage/evaluate'); aw.toast(`평가 ${r.evaluated}건 · 새 알림 ${r.notified.length}건`); load(); } catch (err) { aw.toast(err.message); } } });
|
||||
load();
|
||||
})();
|
||||
Reference in New Issue
Block a user