/* portal/ui/public/app.js — 고객 포털 공통 화면 스크립트. * aw.api(method, path, body) : 같은 origin 의 /api/* 호출. CSRF 헤더 자동, 오류는 { ok:false, error, message } 로 던진다. * aw.state(el, kind, text) : 불러오는 중·비어 있음·지연·부분 실패·전체 실패·권한 없음 공통 표시 * aw.toast(text) : 짧은 알림 * aw.confirmDanger(spec) : 파괴적 작업 확인(대상·영향·입력 확인) → Promise * aw.poll(fn, ms) : 긴 작업 상태를 화면을 떠나지 않고 갱신(탭이 숨겨지면 느리게) * aw.job(el, job) : 작업 상태 블록(현재 단계·완료 단계·다음 단계·재시도·취소·되돌리기) 렌더 */ window.aw = (() => { const csrf = () => (document.querySelector('meta[name=csrf]') || {}).content || ''; async function api(method, path, body) { const r = await fetch(path, { method, headers: { 'Content-Type': 'application/json', 'X-CSRF': csrf() }, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'same-origin' }); let j = null; try { j = await r.json(); } catch (_) {} if (!r.ok || (j && j.ok === false)) { const e = new Error((j && j.message) || `오류 ${r.status}`); e.code = (j && j.error) || 'http_' + r.status; e.detail = j && j.detail; e.status = r.status; throw e; } return j; } const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const LABEL = { loading: '불러오는 중', empty: '비어 있음', stale: '정보 확인 지연', partial: '부분 실패', failed: '전체 실패', forbidden: '권한 없음' }; function state(el, kind, text, opts = {}) { if (typeof el === 'string') el = document.querySelector(el); if (!el) return; el.innerHTML = `
${esc(LABEL[kind] || kind)}${text ? `

${esc(text)}

` : ''}${opts.retry ? '' : ''}${opts.href ? `${esc(opts.hrefLabel || '이동')}` : ''}
`; if (opts.retry) el.querySelector('[data-retry]').onclick = opts.retry; } let toastT; function toast(text) { const t = document.getElementById('toast'); t.textContent = text; t.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove('show'), 4000); } function modal(html) { const m = document.getElementById('modal'); m.innerHTML = ``; m.hidden = false; m.onclick = (e) => { if (e.target === m) close(); }; function close() { m.hidden = true; m.innerHTML = ''; } return { el: m, close }; } function confirmDanger(spec) { return new Promise((resolve) => { const m = modal(`

${esc(spec.title)}

대상: ${esc(spec.target)}

${spec.impacts && spec.impacts.length ? `

영향:

` : ''}${spec.typeToConfirm ? `

` : ''}
`); const okb = m.el.querySelector('#dz-ok'); const inp = m.el.querySelector('#dz-in'); if (inp) inp.oninput = () => { okb.disabled = inp.value.trim() !== spec.typeToConfirm; }; m.el.querySelector('#dz-c').onclick = () => { m.close(); resolve(false); }; okb.onclick = () => { m.close(); resolve(true); }; }); } function poll(fn, ms = 3000) { let stop = false; (async function loop() { while (!stop) { try { const more = await fn(); if (more === false) break; } catch (e) { /* 화면은 상태 패널이 보여 준다 */ } await new Promise((r) => setTimeout(r, document.hidden ? ms * 4 : ms)); } })(); return () => { stop = true; }; } const JOB_LABEL = { not_ready: '준비 전', needs_setup: '설정 필요', awaiting_approval: '승인 대기', awaiting_payment: '결제 대기', queued: '처리 대기', running: '진행 중', awaiting_external: '외부 작업 대기', verifying: '검증 중', paused: '일시 중지', done: '완료', failed: '실패', reverting: '되돌리는 중', reverted: '되돌림 완료', needs_user: '사용자 확인 필요', cancelled: '취소됨' }; const SYM = { done: '✔', running: '◌', failed: '✘', waiting: '◔', needs_user: '!', pending: '○' }; function fmt(iso) { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? iso : d.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', hour12: false }); } function job(el, j, { onAction } = {}) { if (typeof el === 'string') el = document.querySelector(el); if (!el || !j) return; const kind = ['done'].includes(j.status) ? 'done' : ['failed'].includes(j.status) ? 'failed' : ['running', 'verifying', 'reverting'].includes(j.status) ? 'running' : ['awaiting_external', 'needs_user', 'paused', 'awaiting_approval', 'awaiting_payment'].includes(j.status) ? 'waiting' : 'neutral'; el.innerHTML = `
${SYM[kind === 'waiting' ? 'waiting' : kind] || '○'} ${esc(JOB_LABEL[j.status] || j.status)}${j.stale ? '정보 확인 지연 · ' : ''}마지막 갱신 ${esc(fmt(j.updatedAt))}
${j.progress ? `
` : ''}
${j.currentStep ? `
현재 단계
${esc(j.currentStep.title)}
` : ''}${j.nextStep ? `
다음 단계
${esc(j.nextStep.title)}
` : ''}
예상 완료
${j.etaAt ? esc(fmt(j.etaAt)) : esc(j.etaUnknownReason || '산정할 수 없음')}
${j.maxAttempts > 1 ? `
자동 재시도
${j.attempt}/${j.maxAttempts}회${j.nextRunAt && j.status === 'queued' ? ` · 다음 ${esc(fmt(j.nextRunAt))}` : ''}
` : ''}${j.waitReason ? `
대기 사유
${esc(j.waitReason)}
` : ''}${j.userMessage ? `
필요한 조치
${esc(j.userMessage)}
` : ''}${j.error ? `
오류
${esc(j.error)} (문의 번호 ${esc(j.id)})
` : ''}
${(j.actions || []).map((a) => ``).join('')}${j.canRetry ? '' : ''}${j.canResume && !(j.actions || []).length ? '' : ''}${j.canCancel ? '' : ''}${j.canRevert ? '' : ''}
`; el.querySelectorAll('[data-ja]').forEach((b) => { b.onclick = () => onAction && onAction(b.dataset.ja, j); }); } document.addEventListener('click', (e) => { const a = e.target.closest('[data-action]'); if (!a) return; if (a.dataset.action === 'toggle-nav') document.getElementById('side')?.classList.toggle('open'); if (a.classList.contains('copy')) { navigator.clipboard?.writeText(a.dataset.copy || a.textContent); toast('복사했습니다'); } }); document.addEventListener('change', async (e) => { if (e.target.matches('[data-action=switch-group]')) { try { await api('POST', '/api/groups/switch', { groupId: e.target.value }); location.reload(); } catch (err) { toast(err.message); } } }); // 저장하지 않은 변경의 이탈 경고(§9.3): 폼이 data-dirty-guard 를 가지면 입력 뒤 떠날 때 경고 let dirty = false; document.addEventListener('input', (e) => { if (e.target.closest('form[data-dirty-guard]')) dirty = true; }); document.addEventListener('submit', () => { dirty = false; }); window.addEventListener('beforeunload', (e) => { if (dirty) { e.preventDefault(); e.returnValue = ''; } }); return { api, esc, state, toast, modal, confirmDanger, poll, job, fmt, JOB_LABEL, markClean: () => { dirty = false; } }; })();