Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
26 lines
9.3 KiB
JavaScript
26 lines
9.3 KiB
JavaScript
/* portal/transfer/public/transfer.js — 소유권 이전 화면(§22): 목록·새 요청(범위 미리보기)·상세(수락·거절·취소·분쟁) */
|
|
(() => {
|
|
const { api, esc, state, toast, confirmDanger, fmt } = window.aw;
|
|
const ST = { requested: ['waiting', '수락 대기'], accepted: ['waiting', '권리 확인'], on_hold: ['warning', '보류(권리 부족)'], reviewing: ['waiting', '운영자 심사'], approved: ['running', '승인됨'], completed: ['done', '완료'], rejected: ['failed', '거절(운영자)'], declined: ['failed', '거절(받는 쪽)'], cancelled: ['neutral', '취소'], expired: ['neutral', '만료'], disputed: ['failed', '분쟁 보류'] };
|
|
const badge = (k) => { const [c, l] = ST[k] || ['neutral', k]; return `<span class="badge ${c}">${esc(l)}</span>`; };
|
|
const scopeHtml = (sc, ex) => !sc ? '' : `<h3>이전 대상</h3>${sc.sites.map((s) => `<div class="card"><b>${esc(s.name)}</b> <span class="sub">${esc(s.lifecycle)}${s.siteCount === 'in_use' ? ' · 운영 사이트 수 사용 중' : ''}</span><dl class="kv"><dt>도메인</dt><dd>${s.domains.map((d) => esc(d.name) + (d.is_primary ? '(대표)' : '')).join(', ') || '없음'}</dd><dt>사이트 대표 메일</dt><dd>${s.siteMail.map((m) => esc(m.domain)).join(', ') || '없음'}</dd><dt>백업</dt><dd>${s.backup ? `옵션 ${esc(s.backup.status)} · 백업 ${s.backup.backups}개` : '없음'}</dd><dt>배정 용량</dt><dd>${s.allocations.map((a) => `${esc(a.resource)} ${a.amount}`).join(', ') || '없음'}</dd><dt>진행 작업</dt><dd>${s.inProgress.length ? s.inProgress.map((c) => esc(c.type + ' ' + c.status)).join(', ') : '없음'}</dd><dt>미납</dt><dd>${s.unpaid.length ? s.unpaid.map((o) => `${esc(o.order_no)} ${o.total}`).join(', ') : '없음'}</dd></dl></div>`).join('')}${sc.mailServices.map((m) => `<div class="card"><b>${esc(m.name)}</b> (독립 메일 ${esc(m.domain)}) <span class="sub">메일함 ${m.mailboxes}개 · 배정 ${m.allocations.map((a) => `${esc(a.resource)} ${a.amount}`).join(', ') || '없음'}</span></div>`).join('')}<p class="sub"><b>이전하지 않는 항목:</b> ${ex.map(esc).join(', ')}</p>`;
|
|
const list = document.getElementById('tr-list');
|
|
if (list) (async () => { try { const d = await api('GET', '/api/transfer'); if (!d.transfers.length) return state(list, 'empty', '소유권 이전 요청이 없습니다'); list.innerHTML = `<table class="r"><thead><tr><th>상태</th><th>방향</th><th>상대</th><th>대상</th><th>요청</th><th>만료</th></tr></thead><tbody>${d.transfers.map((x) => `<tr><td data-l="상태">${badge(x.status)}</td><td data-l="방향">${x.role === 'sender' ? '보냄' : '받음'}</td><td data-l="상대">${esc(x.role === 'sender' ? x.toGroupName : x.fromGroupName)}</td><td data-l="대상"><a href="/transfers/${x.id}">사이트 ${(x.scope.siteIds || []).length} · 메일 ${(x.scope.mailServiceIds || []).length}</a></td><td data-l="요청">${esc(fmt(x.created_at))}</td><td data-l="만료">${esc(fmt(x.expires_at))}</td></tr>`).join('')}</tbody></table>`; } catch (e) { state(list, e.status === 403 ? 'forbidden' : 'failed', e.message); } })();
|
|
const nw = document.getElementById('tr-new');
|
|
if (nw) (async () => { try { const d = await api('GET', '/api/transfer/candidates'); nw.innerHTML = `<form class="card" id="tr-form" data-dirty-guard><h2>대상 선택</h2>${d.sites.length ? d.sites.map((s) => `<label class="f"><span><input type="checkbox" name="site" value="${esc(s.id)}"> ${esc(s.name)} <span class="sub">${esc(s.primary_domain || '')} · ${esc(s.lifecycle)}</span></span></label>`).join('') : '<p class="muted">사이트가 없습니다</p>'}<h3>독립 메일 서비스(명시적으로 고른 것만 포함)</h3>${d.mailServices.length ? d.mailServices.map((m) => `<label class="f"><span><input type="checkbox" name="mail" value="${esc(m.id)}"> ${esc(m.name)} (${esc(m.domain)})</span></label>`).join('') : '<p class="muted">없음</p>'}<div class="f"><label for="to">받는 사람 이메일</label><input id="to" name="toEmail" type="email" required placeholder="receiver@example.com"><span class="hint">받는 사람이 가입 사용자이면 알림으로, 아니면 이메일로 수락 링크가 전달됩니다. 요청은 7일 뒤 만료되며 운영자 승인 전까지 취소할 수 있습니다</span></div><p class="sub"><b>이전하지 않는 항목:</b> ${d.excluded.map(esc).join(', ')}</p><div id="tr-preview"></div><div class="actions"><button type="button" class="btn" id="tr-prev">범위 미리보기</button><button type="submit" class="btn primary">이전 요청</button></div></form>`;
|
|
const sel = () => ({ siteIds: [...nw.querySelectorAll('input[name=site]:checked')].map((i) => i.value), mailServiceIds: [...nw.querySelectorAll('input[name=mail]:checked')].map((i) => i.value) });
|
|
document.getElementById('tr-prev').onclick = async () => { try { const r = await api('POST', '/api/transfer/preview', sel()); document.getElementById('tr-preview').innerHTML = scopeHtml(r.scope, d.excluded) + (r.scope.blocking.length ? `<p class="err">진행 중 작업이 끝나야 요청할 수 있습니다: ${r.scope.blocking.map((b) => esc(b.subject + ' ' + b.type)).join(', ')}</p>` : ''); } catch (e) { toast(e.message); } };
|
|
document.getElementById('tr-form').onsubmit = async (ev) => { ev.preventDefault(); const s = sel(); const to = document.getElementById('to').value.trim(); if (!(await confirmDanger({ title: '소유권 이전 요청', target: `${to} 에게 사이트 ${s.siteIds.length}개 · 메일 ${s.mailServiceIds.length}개`, impacts: ['받는 쪽 수락과 운영자 심사 뒤 한 번에 이전됩니다', '완료 시각부터 청구 책임이 넘어갑니다', '이전 뒤 우리 그룹 구성원은 접근할 수 없습니다'], typeToConfirm: '이전', confirmLabel: '요청' }))) return; try { const r = await api('POST', '/api/transfer', { ...s, toEmail: to, confirm: true }); window.aw.markClean(); location.href = `/transfers/${r.transfer.id}`; } catch (e) { toast(e.message); } };
|
|
} catch (e) { state(nw, e.status === 403 ? 'forbidden' : 'failed', e.message); } })();
|
|
const det = document.getElementById('tr-detail');
|
|
if (det) { const id = det.dataset.id; const token = new URLSearchParams(location.search).get('token'); const render = async () => { try { const d = await api('GET', `/api/transfer/${id}${token ? '?token=' + encodeURIComponent(token) : ''}`); const x = d.transfer;
|
|
det.innerHTML = `<div class="card"><h2>${badge(x.status)} ${esc(x.fromGroupName)} → ${esc(x.toGroupName)}</h2><dl class="kv"><dt>요청</dt><dd>${esc(fmt(x.created_at))}</dd><dt>만료</dt><dd>${esc(fmt(x.expires_at))}</dd><dt>취소 가능</dt><dd>${esc(x.cancelDeadline)} (보내는 쪽) · ${esc(x.declineDeadline)} (받는 쪽)</dd><dt>청구</dt><dd>${x.billing.senderEndsAt ? `종료/시작 ${esc(fmt(x.billing.senderEndsAt))} · ` : ''}${esc(x.billing.note)}</dd>${x.hold ? `<dt>보류 사유</dt><dd class="err">${x.hold.map((h) => `${esc(h.label)} ${h.shortage} 부족 — ${esc(h.product)}`).join('<br>')}</dd>` : ''}${x.status === 'disputed' ? `<dt>분쟁</dt><dd class="err">기존 서비스 유지 · 설정 변경·삭제·추가 이전 동결 · 문의: 운영자 지원 창구 ${esc(x.dispute_note || '')}</dd>` : ''}${x.completion ? `<dt>완료</dt><dd>사이트 ${x.completion.moved.sites} · 도메인 ${x.completion.moved.domains} · 메일 ${x.completion.moved.mailServices} · 배정 ${x.completion.moved.allocations}</dd>` : ''}</dl>
|
|
${scopeHtml(x.scopeDetail, x.excluded)}
|
|
<div class="actions">${x.canAccept ? '<button class="btn primary" id="t-acc">수락(현재 그룹으로 받기)</button>' : ''}${x.canRecheck ? '<button class="btn" id="t-re">권리 다시 확인</button>' : ''}${x.canDecline ? '<button class="btn" id="t-dec">거절</button>' : ''}${x.canCancel ? '<button class="btn danger" id="t-can">요청 취소</button>' : ''}${x.canDispute ? '<button class="btn danger" id="t-dis">분쟁 신고</button>' : ''}</div></div>`;
|
|
const q = (i) => document.getElementById(i); const act = async (p, body) => { try { await api('POST', `/api/transfer/${id}/${p}`, body); toast('처리했습니다'); render(); } catch (e) { toast(e.message); } };
|
|
if (q('t-acc')) q('t-acc').onclick = async () => { if (await confirmDanger({ title: '소유권 이전 수락', target: '현재 사용자 그룹으로 받습니다', impacts: ['우리 그룹의 상품 권리로 대상을 받습니다(부족하면 보류)', '운영자 심사 뒤 완료 시각부터 청구 책임이 시작됩니다'], confirmLabel: '수락' })) act('accept', { confirm: true, token }); };
|
|
if (q('t-re')) q('t-re').onclick = () => act('recheck'); if (q('t-dec')) q('t-dec').onclick = () => act('decline', { reason: '' }); if (q('t-can')) q('t-can').onclick = async () => { if (await confirmDanger({ title: '요청 취소', target: '이 이전 요청', impacts: ['받는 쪽에 취소가 알려집니다'], confirmLabel: '취소' })) act('cancel'); };
|
|
if (q('t-dis')) q('t-dis').onclick = async () => { if (await confirmDanger({ title: '분쟁 신고', target: '이 이전 요청', impacts: ['기존 서비스는 유지됩니다', '설정 변경·삭제·추가 이전이 동결됩니다', '운영자가 확인할 때까지 보류됩니다'], typeToConfirm: '분쟁', confirmLabel: '신고' })) act('dispute', { confirm: true, note: '' }); };
|
|
} catch (e) { state(det, e.status === 403 ? 'forbidden' : e.status === 404 ? 'empty' : 'failed', e.message); } }; render(); }
|
|
})();
|