'use strict'; /* ============================================================================ * 사이트 관리자 페이지 (v1.0.3) — /site-admin/<사이트ID>/ * 켜진 모듈(회원·게시판·문의·설문·예약·갤러리·지도·후기·FAQ·블로그·자료실·일정관리)의 데이터와 설정을 관리한다. * 인증: 빌더 세션 쿠키(소유자/운영자) 가 있으면 그대로, 없으면 사이트 관리자 계정(회원 role=admin)으로 로그인해 토큰(localStorage)으로 호출한다. * 화면은 lib/modules.js 의 CATALOG(옵션 스키마·표 열) 로 대부분 범용 렌더링하고, 설문 양식 편집기·갤러리 업로드 같은 것만 전용 UI 를 둔다. * ========================================================================== */ const $ = (id) => document.getElementById(id); const SITE = (location.pathname.match(/\/site-admin\/([a-f0-9]{32})/) || [])[1]; const EMBED = new URLSearchParams(location.search).get('embed') === '1'; const TOKEN_KEY = 'wm_siteadmin_' + SITE; let token = ''; try { token = localStorage.getItem(TOKEN_KEY) || ''; } catch (_) {} let ov = null; let tab = 'home'; const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const dt = (s) => (s ? new Date(s).toLocaleString(uiLocale(), { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''); async function api(p, opt = {}) { const headers = opt.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }; if (token) headers.Authorization = 'Bearer ' + token; const r = await fetch(`/api/site-admin/${SITE}${p}`, { credentials: 'same-origin', ...opt, headers: { ...headers, ...(opt.headers || {}) } }); if (r.status === 401) { showLogin(); throw new Error('로그인이 필요합니다'); } const ct = r.headers.get('content-type') || ''; const body = ct.includes('json') ? await r.json().catch(() => ({})) : await r.blob(); if (!r.ok) throw new Error((body && body.error) || `HTTP ${r.status}`); return body; } function showLogin() { $('loginBox').classList.remove('hidden'); $('app').classList.add('hidden'); } $('lGo').onclick = async () => { $('lMsg').textContent = '확인 중…'; try { const r = await fetch(`/api/m/${SITE}/members/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: $('lId').value.trim(), password: $('lPw').value }) }); const j = await r.json(); if (!r.ok) throw new Error(j.error || '로그인 실패'); if (j.member.role !== 'admin') throw new Error('사이트 관리자 권한이 없는 계정입니다'); token = j.token; try { localStorage.setItem(TOKEN_KEY, token); } catch (_) {} await boot(); } catch (e) { $('lMsg').textContent = e.message; $('lMsg').className = 'msg err'; } }; $('btnLogout').onclick = () => { token = ''; try { localStorage.removeItem(TOKEN_KEY); } catch (_) {} showLogin(); }; // ---------- 공통 렌더 ---------- const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; }; function btn(label, cls, fn) { const b = el('button', cls, label); b.type = 'button'; b.onclick = fn; return b; } /** 설정 스키마(옵션) → 입력 폼 */ function settingsForm(mod, into) { const cat = ov.catalog.find((c) => c.id === mod); const cur = ov.settings[mod] || {}; const kv = el('div', 'kv'); for (const o of cat.options || []) { kv.appendChild(el('label', '', o.label)); let inp; if (o.type === 'boolean') { inp = el('input'); inp.type = 'checkbox'; inp.checked = !!cur[o.key]; inp.style.width = 'auto'; } else if (o.type === 'select') { inp = el('select'); for (const [v, l] of o.values) inp.appendChild(new Option(l, v)); inp.value = cur[o.key] == null ? o.def : cur[o.key]; } else if (o.type === 'list') { inp = el('input'); inp.value = (cur[o.key] || []).join(', '); inp.placeholder = '쉼표로 구분'; } else if (o.type === 'time') { inp = el('input'); inp.type = 'time'; inp.value = cur[o.key] || o.def; } else { inp = el('input'); inp.type = o.type === 'number' ? 'number' : 'text'; inp.value = cur[o.key] == null ? '' : cur[o.key]; } inp.dataset.key = o.key; inp.dataset.type = o.type; kv.appendChild(inp); } into.appendChild(kv); const row = el('div', 'row'); const m = el('span', 'msg'); row.appendChild(btn('설정 저장', 'primary', async () => { const body = {}; kv.querySelectorAll('[data-key]').forEach((i) => { body[i.dataset.key] = i.dataset.type === 'boolean' ? i.checked : i.value; }); try { ov.settings[mod] = await api(`/settings/${mod}`, { method: 'PUT', body: JSON.stringify(body) }); m.textContent = '저장되었습니다'; m.className = 'msg ok'; } catch (e) { m.textContent = e.message; m.className = 'msg err'; } })); row.appendChild(m); into.appendChild(row); } /** 범용 표: 열 정의대로 표시, 수정/삭제 */ async function table(coll, into, opts = {}) { const items = await api(`/c/${coll}`); const cols = opts.columns || []; const wrap = el('div', 'table-wrap'); const t = el('table'); const tr = el('tr'); for (const c of cols) tr.appendChild(el('th', '', LABEL[c] || c)); tr.appendChild(el('th', '', '')); t.appendChild(tr); for (const it of items) { const r = el('tr'); for (const c of cols) { const td = el('td'); let v = it[c]; if (opts.format && opts.format[c]) v = opts.format[c](v, it); /* v1.4.18 format 에 항목 전체도 넘긴다 */ else if (typeof v === 'number' && Number.isFinite(v)) v = v.toLocaleString(uiLocale()); /* v1.4.5 숫자 3자리 쉼표 */ else if (c === 'createdAt' || c === 'at') v = dt(v); else if (typeof v === 'boolean') { const p = el('span', 'pill ' + (v ? 'ok' : 'warn'), v ? (c === 'approved' ? '승인' : c === 'sent' ? '발송' : '예') : (c === 'approved' ? '대기' : c === 'sent' ? '미발송' : '아니오')); td.appendChild(p); r.appendChild(td); continue; } else if (Array.isArray(v)) v = v.join(', '); else if (c === 'status') { const p = el('span', 'pill ' + (v === 'confirmed' ? 'ok' : v === 'cancelled' ? 'off' : 'warn'), STATUS[v] || v); td.appendChild(p); r.appendChild(td); continue; } else if (c === 'role') v = v === 'admin' ? '관리자' : '회원'; else if (c === 'rating') v = '★'.repeat(v || 0); const d = el('div', 'wrap', v == null ? '' : String(v)); td.appendChild(d); r.appendChild(td); } const act = el('td', 'act'); for (const a of (opts.actions || [])) act.appendChild(btn(a.label, 'small' + (a.danger ? ' danger' : ''), () => a.fn(it))); if (opts.edit) act.appendChild(btn('수정', 'small', () => opts.edit(it))); /* v1.4.18 전용 편집기(일정) */ else if (opts.editable !== false) act.appendChild(btn('수정', 'small', () => editDialog(coll, it, opts))); act.appendChild(btn('삭제', 'small danger', async () => { if (!confirm('삭제할까요?')) return; await api(`/c/${coll}/${it.id}`, { method: 'DELETE' }); render(); })); r.appendChild(act); t.appendChild(r); } if (!items.length) { const r = el('tr'); const td = el('td', 'muted', '아직 항목이 없습니다'); td.colSpan = cols.length + 1; r.appendChild(td); t.appendChild(r); } wrap.appendChild(t); into.appendChild(wrap); return items; } const LABEL = { when: '날짜·시간', unit: '단위', repeat: '반복', next: '다음 발생', location: '장소', published: '공개', pinned: '고정', summary: '요약', cover: '대표 이미지', description: '설명', originalName: '파일', size: '크기', downloads: '내려받음', mailAddress: '메일 주소', name: '이름', email: '이메일', role: '권한', approved: '승인', createdAt: '등록', title: '제목', author: '글쓴이', category: '분류', secret: '비밀', views: '조회', postId: '글', body: '내용', active: '진행', responses: '응답 수', surveyId: '설문', respondent: '응답자', date: '날짜', time: '시간', contact: '연락처', status: '상태', note: '요청', tags: '태그', src: '이미지', q: '질문', a: '답변', order: '순서', rating: '별점', text: '내용', subject: '제목', sent: '메일', at: '접수', loginId: '아이디', caption: '설명' }; const STATUS = { pending: '대기', confirmed: '확정', cancelled: '취소' }; /** 수정/추가 대화상자: 필드 정의에 따라 입력 생성 */ const FIELDS = { members: [['name', '이름'], ['email', '이메일'], ['role', '권한', 'select', [['member', '회원'], ['admin', '관리자']]], ['approved', '승인', 'bool']], posts: [['title', '제목'], ['category', '분류'], ['author', '글쓴이'], ['body', '내용', 'text'], ['secret', '비밀글', 'bool'], ['pinned', '상단 고정', 'bool']], comments: [['body', '내용', 'text'], ['secret', '비밀 댓글', 'bool']], bookings: [['date', '날짜', 'date'], ['time', '시간', 'time'], ['name', '이름'], ['contact', '연락처'], ['note', '요청', 'text'], ['status', '상태', 'select', [['pending', '대기'], ['confirmed', '확정'], ['cancelled', '취소']]]], reviews: [['name', '이름'], ['rating', '별점(1~5)', 'number'], ['category', '분류'], ['text', '내용', 'text'], ['approved', '공개', 'bool']], faq: [['q', '질문'], ['a', '답변', 'text'], ['category', '분류'], ['order', '순서', 'number']], gallery: [['src', '이미지 주소'], ['thumb', '썸네일 주소(선택)'], ['title', '제목'], ['caption', '설명'], ['tags', '태그(쉼표)'], ['order', '순서', 'number']], // v1.4.0 블로그·자료실 blogPosts: [['title', '제목'], ['summary', '요약(비우면 본문 앞부분)'], ['body', '본문 (빈 줄 = 문단, 주소는 자동 링크)', 'text'], ['cover', '대표 이미지 주소(선택)'], ['category', '분류'], ['tags', '태그(쉼표)'], ['author', '글쓴이'], ['published', '공개', 'bool'], ['pinned', '상단 고정', 'bool']], blogComments: [['name', '이름'], ['body', '내용', 'text'], ['approved', '공개', 'bool']], libraryFiles: [['title', '제목'], ['description', '설명', 'text'], ['category', '분류'], ['tags', '태그(쉼표)'], ['published', '공개', 'bool'], ['pinned', '상단 고정', 'bool']], }; /** v1.4.0 바이트 → 읽기 쉬운 크기 */ const fmtSize = (n) => { n = Number(n) || 0; return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : n < 1073741824 ? `${(n / 1048576).toFixed(1)} MB` : `${(n / 1073741824).toFixed(2)} GB`; }; function editDialog(coll, item, opts) { const fields = FIELDS[coll]; if (!fields) return; const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = (item ? '수정' : '추가') + ' — ' + ((opts && opts.label) || coll); $('dlgMsg').textContent = ''; const inputs = {}; for (const [k, label, type, values] of fields) { const lab = el('label', '', label); let inp; if (type === 'text') { inp = el('textarea'); inp.rows = 5; inp.value = item ? (item[k] || '') : ''; } else if (type === 'bool') { inp = el('input'); inp.type = 'checkbox'; inp.style.width = 'auto'; inp.checked = item ? !!item[k] : (k === 'approved'); } else if (type === 'select') { inp = el('select'); for (const [v, l] of values) inp.appendChild(new Option(l, v)); if (item) inp.value = item[k]; } else { inp = el('input'); inp.type = type === 'number' ? 'number' : type === 'date' ? 'date' : type === 'time' ? 'time' : 'text'; inp.value = item ? (Array.isArray(item[k]) ? item[k].join(', ') : (item[k] == null ? '' : item[k])) : ''; } inputs[k] = [inp, type]; lab.appendChild(inp); body.appendChild(lab); } $('dlgSave').onclick = async () => { const data = {}; for (const [k, [inp, type]] of Object.entries(inputs)) data[k] = type === 'bool' ? inp.checked : inp.value; try { if (item) await api(`/c/${coll}/${item.id}`, { method: 'PUT', body: JSON.stringify(data) }); else await api(`/c/${coll}`, { method: 'POST', body: JSON.stringify(data) }); $('dlg').close(); render(); } catch (e) { $('dlgMsg').textContent = e.message; $('dlgMsg').className = 'msg err'; } }; $('dlgCancel').onclick = () => $('dlg').close(); $('dlg').showModal(); } // ---------- 화면 ---------- async function boot() { try { ov = await api('/overview'); } catch (e) { return; } $('loginBox').classList.add('hidden'); $('app').classList.remove('hidden'); $('hSite').textContent = ov.site ? ov.site.title : SITE.slice(0, 8); $('hWho').textContent = ov.who || ''; $('btnLogout').hidden = !token; if (EMBED) document.querySelector('header').style.display = 'none'; // v1.1.4: 이 페이지의 직접 접근 주소 (빌더 없이 들어오면 사이트 관리자 계정으로 로그인) let bar = document.getElementById('saUrl'); if (!bar) { bar = document.createElement('div'); bar.id = 'saUrl'; bar.className = 'sa-url'; document.querySelector('header').insertAdjacentElement('afterend', bar); } const direct = `${location.origin}/site-admin/${SITE}/`; bar.innerHTML = ''; bar.appendChild(Object.assign(document.createElement('span'), { textContent: '관리자 페이지 주소:' })); const code = document.createElement('code'); code.textContent = direct; bar.appendChild(code); const cp = btn('복사', 'small', async () => { try { await navigator.clipboard.writeText(direct); cp.textContent = '복사됨'; setTimeout(() => (cp.textContent = '복사'), 1500); } catch (_) { prompt('주소', direct); } }); bar.appendChild(cp); bar.appendChild(btn('새 창으로 열기', 'small', () => window.open(direct, '_blank', 'noopener'))); bar.appendChild(Object.assign(document.createElement('span'), { className: 'muted', textContent: '— 빌더를 거치지 않고 이 주소로 들어오면 "관리자 계정" 탭에서 만든 사이트 관리자 계정으로 로그인해야 합니다.' })); renderTabs(); render(); } function renderTabs() { const tabs = $('tabs'); tabs.innerHTML = ''; const add = (id, label, n) => { const b = btn(label, tab === id ? 'active' : '', () => { tab = id; renderTabs(); render(); }); if (n) { const s = el('span', 'n', `(${n})`); b.appendChild(s); } tabs.appendChild(b); }; add('home', '모듈 · 설정'); for (const c of ov.catalog) if (ov.enabled[c.id] && Object.keys(c.collections).length) add(c.id, c.label, c.id === 'booking' ? ov.counts.pendingBookings : c.id === 'reviews' ? ov.counts.pendingReviews : c.id === 'members' ? ov.counts.pendingMembers : null); if (ov.enabled.inquiry) add('inquiry', '문의', ov.counts.unsentInquiries || null); if (ov.mail && ov.mail.systemEnabled) add('mail', '메일'); // v1.3.2: 운영자가 이 사이트의 메일 시스템을 켰을 때만 (설정중 포함) add('account', '관리자 계정'); } async function render() { const m = $('main'); m.innerHTML = ''; try { if (tab === 'home') return renderHome(m); if (tab === 'account') return renderAccount(m); if (tab === 'mail') return renderMail(m); if (tab === 'inquiry') return renderInquiries(m); const cat = ov.catalog.find((c) => c.id === tab); if (!cat) return; const card = el('div', 'card'); card.appendChild(el('h2', '', cat.label + ' 설정')); settingsForm(tab, card); m.appendChild(card); for (const [coll, def] of Object.entries(cat.collections)) { const c2 = el('div', 'card'); const h = el('h2', '', def.label); c2.appendChild(h); if (coll === 'surveys') { h.appendChild(btn('+ 설문 만들기', 'small primary', () => surveyEditor(null))); await table(coll, c2, { columns: def.columns, editable: false, actions: [{ label: '문항 편집', fn: (it) => surveyEditor(it) }, { label: '응답 보기', fn: (it) => { tab = 'survey'; renderResponses(m, it); } }, { label: 'xlsx 내려받기', fn: (it) => { window.open(`/api/site-admin/${SITE}/export/survey/${it.id}.xlsx${token ? '?token=' + encodeURIComponent(token) : ''}`, '_blank'); } }] }); } else if (coll === 'responses') { c2.appendChild(el('p', 'muted small', '응답은 설문 표의 "응답 보기"에서 설문별로 확인하고 xlsx 로 내려받을 수 있습니다.')); } else if (coll === 'gallery') { h.appendChild(btn('+ 이미지 주소로 추가', 'small', () => editDialog(coll, null, def))); const up = el('input'); up.type = 'file'; up.accept = 'image/*'; up.multiple = true; up.style.width = 'auto'; up.onchange = async () => { const fd = new FormData(); for (const f of up.files) fd.append('files', f, f.name); try { await api('/upload', { method: 'POST', body: fd }); render(); } catch (e) { alert(e.message); } }; const lab = el('label', '', '이미지 업로드(여러 장 가능): '); lab.appendChild(up); c2.appendChild(lab); await table(coll, c2, { columns: def.columns, label: def.label }); } // v1.4.0 블로그 글: 추가 + 대표 이미지 올리기(올린 이미지 주소를 글의 "대표 이미지 주소" 에 붙여 넣는다) else if (coll === 'blogPosts') { h.appendChild(btn('+ 글쓰기', 'small primary', () => editDialog(coll, null, def))); const up = el('input'); up.type = 'file'; up.accept = 'image/*'; up.style.width = 'auto'; const out = el('span', 'muted small'); up.onchange = async () => { const fd = new FormData(); for (const f of up.files) fd.append('files', f, f.name); try { const r = await api('/upload?to=blog', { method: 'POST', body: fd }); out.textContent = r.added.length ? `올린 이미지 주소: ${r.added.map((a) => a.url).join(' , ')} (글의 대표 이미지 주소에 붙여 넣으세요)` : '이미지 파일(15MB 이하)만 올릴 수 있습니다'; } catch (e) { alert(e.message); } }; const lab = el('label', '', '대표 이미지 올리기: '); lab.appendChild(up); c2.appendChild(lab); c2.appendChild(out); await table(coll, c2, { columns: def.columns, label: def.label }); } else if (coll === 'blogComments') { let titles = {}; try { titles = Object.fromEntries(((await api('/c/blogPosts')) || []).map((x) => [x.id, x.title])); } catch (_) {} await table(coll, c2, { columns: def.columns, label: def.label, format: { postId: (v) => titles[v] || v }, actions: [{ label: '공개', fn: async (it) => { await api(`/c/blogComments/${it.id}`, { method: 'PUT', body: JSON.stringify({ approved: true }) }); render(); } }] }); } // v1.4.0 자료실: 파일 올리기(여러 개) — 크기 상한은 자료실 설정, 파일은 사이트 폴더 밖에 저장되어 API 로만 내려받는다 else if (coll === 'libraryFiles') { const box = el('div', 'row'); const up = el('input'); up.type = 'file'; up.multiple = true; up.style.width = 'auto'; const cat = el('input'); cat.placeholder = '분류(선택)'; cat.style.width = '140px'; const out = el('span', 'muted small'); const go = btn('올리기', 'small primary', async () => { if (!up.files.length) { out.textContent = '파일을 고르세요'; return; } const fd = new FormData(); for (const f of up.files) fd.append('files', f, f.name); fd.append('category', cat.value.trim()); out.textContent = '올리는 중…'; try { const r = await api('/library', { method: 'POST', body: fd }); out.textContent = `${r.added.length}개 올림` + (r.skipped.length ? ` · 건너뜀: ${r.skipped.map((x) => `${x.name}(${x.reason})`).join(', ')}` : ''); setTimeout(render, 600); } catch (e) { out.textContent = e.message; } }); box.appendChild(up); box.appendChild(cat); box.appendChild(go); box.appendChild(out); c2.appendChild(box); await table(coll, c2, { columns: def.columns, label: def.label, format: { size: fmtSize } }); } else if (coll === 'events') { await renderCalendarEvents(c2, def, h); } // v1.4.18 일정관리 else if (coll === 'members') { await table(coll, c2, { columns: def.columns, label: def.label, actions: [{ label: '승인', fn: async (it) => { await api(`/c/members/${it.id}`, { method: 'PUT', body: JSON.stringify({ approved: true }) }); render(); } }] }); } else if (coll === 'bookings') { h.appendChild(btn('+ 예약 추가', 'small', () => editDialog(coll, null, def))); await table(coll, c2, { columns: def.columns, label: def.label, actions: [{ label: '확정', fn: async (it) => { await api(`/c/bookings/${it.id}`, { method: 'PUT', body: JSON.stringify({ status: 'confirmed' }) }); render(); } }, { label: '취소', fn: async (it) => { await api(`/c/bookings/${it.id}`, { method: 'PUT', body: JSON.stringify({ status: 'cancelled' }) }); render(); } }] }); } else if (coll === 'reviews') { await table(coll, c2, { columns: def.columns, label: def.label, actions: [{ label: '공개', fn: async (it) => { await api(`/c/reviews/${it.id}`, { method: 'PUT', body: JSON.stringify({ approved: true }) }); render(); } }] }); } else { if (FIELDS[coll] && coll !== 'comments') h.appendChild(btn('+ 추가', 'small', () => editDialog(coll, null, def))); await table(coll, c2, { columns: def.columns, label: def.label }); } m.appendChild(c2); } } catch (e) { m.appendChild(el('p', 'msg err', e.message)); } } function renderHome(m) { const card = el('div', 'card'); card.appendChild(el('h2', '', '기능 모듈 켜기 / 끄기')); card.appendChild(el('p', 'muted small', '켜면 이 관리자 페이지에 해당 탭이 생기고, 빌더가 사이트를 만들거나 수정할 때 표준 코드가 삽입됩니다.')); const grid = el('div', 'mod-grid'); for (const c of ov.catalog) { const box = el('div', 'mod'); box.appendChild(el('b', '', c.label)); const sw = el('button', 'switch' + (ov.enabled[c.id] ? ' on' : '')); sw.type = 'button'; sw.onclick = async () => { try { ov.enabled = await api('/enabled', { method: 'PUT', body: JSON.stringify({ module: c.id, on: !ov.enabled[c.id] }) }); sw.classList.toggle('on', !!ov.enabled[c.id]); renderTabs(); } catch (e) { alert(e.message); } }; box.appendChild(sw); box.appendChild(el('div', 'd', c.desc)); grid.appendChild(box); } card.appendChild(grid); m.appendChild(card); const c2 = el('div', 'card'); c2.appendChild(el('h2', '', '사이트 주소')); c2.appendChild(el('p', 'small', ov.site && ov.site.liveUrl ? `운영 주소: ${ov.site.liveUrl}` : '아직 배포되지 않았습니다.')); c2.appendChild(el('p', 'muted small', `모듈 API: ${location.origin}/api/m/${SITE}/… · 이 관리자 페이지: ${location.origin}/site-admin/${SITE}/`)); m.appendChild(c2); } async function renderInquiries(m) { const card = el('div', 'card'); card.appendChild(el('h2', '', '문의 접수')); settingsForm('inquiry', card); m.appendChild(card); const c2 = el('div', 'card'); c2.appendChild(el('h2', '', '접수 내역')); const items = await api('/inquiries'); const t = el('table'); const tr = el('tr'); for (const h of ['접수', '유형', '이름', '이메일', '제목', '내용', '첨부', '메일']) tr.appendChild(el('th', '', h)); t.appendChild(tr); for (const q of items) { const r = el('tr'); for (const v of [dt(q.at), q.category || (q.extra && q.extra.category) || '-', q.name, q.email + (q.phone ? ' / ' + q.phone : ''), q.subject || '-', q.message]) { const td = el('td'); td.appendChild(el('div', 'wrap', v)); r.appendChild(td); } const fa = el('td'); for (const f of q.files || []) { const a = el('a', '', f.name); a.href = `/api/site-admin/${SITE}/files/${encodeURIComponent(f.stored)}${token ? '?token=' + encodeURIComponent(token) : ''}`; a.target = '_blank'; a.style.display = 'block'; fa.appendChild(a); } r.appendChild(fa); const td = el('td'); td.appendChild(el('span', 'pill ' + (q.sent ? 'ok' : 'warn'), q.sent ? '발송' : (q.error ? '미발송' : '기록'))); td.title = q.error || ''; r.appendChild(td); t.appendChild(r); } if (!items.length) { const r = el('tr'); const td = el('td', 'muted', '접수된 문의가 없습니다'); td.colSpan = 8; r.appendChild(td); t.appendChild(r); } const w = el('div', 'table-wrap'); w.appendChild(t); c2.appendChild(w); m.appendChild(c2); } async function renderResponses(m, survey) { m.innerHTML = ''; const card = el('div', 'card'); card.appendChild(el('h2', '', `응답 — ${survey.title}`)); const row = el('div', 'row'); row.appendChild(btn('← 설문 목록', '', () => render())); row.appendChild(btn('xlsx 내려받기', 'primary', () => window.open(`/api/site-admin/${SITE}/export/survey/${survey.id}.xlsx${token ? '?token=' + encodeURIComponent(token) : ''}`, '_blank'))); card.appendChild(row); const items = (await api('/c/responses')).filter((r) => r.surveyId === survey.id); const t = el('table'); const tr = el('tr'); for (const h of ['응답 일시', '응답자', ...survey.questions.map((q) => q.label)]) tr.appendChild(el('th', '', h)); tr.appendChild(el('th')); t.appendChild(tr); for (const r of items) { const row2 = el('tr'); row2.appendChild(el('td', '', dt(r.createdAt))); row2.appendChild(el('td', '', r.respondent)); for (const q of survey.questions) { const v = r.answers[q.id]; const td = el('td'); td.appendChild(el('div', 'wrap', Array.isArray(v) ? v.join(', ') : (v == null ? '' : String(v)))); row2.appendChild(td); } const act = el('td', 'act'); act.appendChild(btn('삭제', 'small danger', async () => { if (confirm('이 응답을 삭제할까요?')) { await api(`/c/responses/${r.id}`, { method: 'DELETE' }); renderResponses(m, survey); } })); row2.appendChild(act); t.appendChild(row2); } if (!items.length) { const r = el('tr'); const td = el('td', 'muted', '응답이 없습니다'); td.colSpan = survey.questions.length + 3; r.appendChild(td); t.appendChild(r); } const w = el('div', 'table-wrap'); w.appendChild(t); card.appendChild(w); m.appendChild(card); } /** 설문 양식 편집기 (구글폼식 문항) */ function surveyEditor(item) { const body = $('dlgBody'); body.innerHTML = ''; $('dlgTitle').textContent = item ? '설문 문항 편집' : '새 설문'; $('dlgMsg').textContent = ''; const title = el('input'); title.placeholder = '설문 제목'; title.value = item ? item.title : ''; const desc = el('textarea'); desc.rows = 2; desc.placeholder = '설명(선택)'; desc.value = item ? item.description : ''; const thanks = el('input'); thanks.placeholder = '제출 후 안내 문구'; thanks.value = item ? item.thanks : '응답해 주셔서 감사합니다.'; const active = el('input'); active.type = 'checkbox'; active.style.width = 'auto'; active.checked = item ? item.active !== false : true; body.appendChild(el('label', '', '제목')).appendChild(title); body.appendChild(el('label', '', '설명')).appendChild(desc); body.appendChild(el('label', '', '제출 후 문구')).appendChild(thanks); const al = el('label', '', '진행 중 (끄면 사이트에서 응답을 받지 않음) '); al.appendChild(active); body.appendChild(al); body.appendChild(el('h3', '', '문항')); const qbox = el('div'); body.appendChild(qbox); const qs = item ? JSON.parse(JSON.stringify(item.questions)) : []; const TYPES = [['short', '단답'], ['long', '장문'], ['single', '객관식(하나)'], ['multi', '객관식(여러 개)'], ['scale', '척도(1~5)'], ['date', '날짜'], ['email', '이메일'], ['number', '숫자']]; const draw = () => { qbox.innerHTML = ''; qs.forEach((q, i) => { const r = el('div', 'q-row'); const lab = el('input'); lab.placeholder = '질문'; lab.value = q.label || ''; lab.oninput = () => { q.label = lab.value; }; const ty = el('select'); for (const [v, l] of TYPES) ty.appendChild(new Option(l, v)); ty.value = q.type || 'short'; ty.onchange = () => { q.type = ty.value; draw(); }; const req = el('label', '', '필수 '); const rc = el('input'); rc.type = 'checkbox'; rc.style.width = 'auto'; rc.checked = !!q.required; rc.onchange = () => { q.required = rc.checked; }; req.appendChild(rc); const rm = btn('×', 'small danger', () => { qs.splice(i, 1); draw(); }); r.appendChild(lab); r.appendChild(ty); r.appendChild(req); r.appendChild(rm); if (q.type === 'single' || q.type === 'multi') { const op = el('textarea'); op.rows = 3; op.placeholder = '선택지 (줄바꿈으로 구분)'; op.value = (q.options || []).join('\n'); op.oninput = () => { q.options = op.value.split('\n').map((x) => x.trim()).filter(Boolean); }; r.appendChild(op); } qbox.appendChild(r); }); qbox.appendChild(btn('+ 문항 추가', 'small', () => { qs.push({ type: 'short', label: '', required: false, options: [] }); draw(); })); }; draw(); $('dlgSave').onclick = async () => { const data = { title: title.value, description: desc.value, thanks: thanks.value, active: active.checked, questions: qs }; try { if (item) await api(`/c/surveys/${item.id}`, { method: 'PUT', body: JSON.stringify(data) }); else await api('/c/surveys', { method: 'POST', body: JSON.stringify(data) }); $('dlg').close(); render(); } catch (e) { $('dlgMsg').textContent = e.message; $('dlgMsg').className = 'msg err'; } }; $('dlgCancel').onclick = () => $('dlg').close(); $('dlg').showModal(); } async function renderAccount(m) { const card = el('div', 'card'); card.appendChild(el('h2', '', '사이트 관리자 계정')); card.appendChild(el('p', 'muted small', '이 관리자 페이지에 빌더 로그인 없이 들어올 수 있는 계정입니다(회원 목록에 "관리자" 권한으로 표시). 사이트 소유자가 직원 등에게 운영을 맡길 때 만듭니다.')); const id = el('input'); id.placeholder = '아이디 (영문·숫자, 로그인에 사용)'; const pw = el('input'); pw.type = 'password'; pw.placeholder = window.pwRuleText ? `비밀번호(${window.pwRuleText})` : '비밀번호(6자 이상)'; const name = el('input'); name.placeholder = '표시 이름'; const msg = el('span', 'msg'); const row = el('div', 'row'); row.appendChild(id); row.appendChild(pw); row.appendChild(name); row.appendChild(btn('만들기', 'primary', async () => { try { const r = await api('/admin-account', { method: 'POST', body: JSON.stringify({ loginId: id.value.trim(), password: pw.value, name: name.value.trim() }) }); id.value = pw.value = name.value = ''; await render(); const m2 = document.getElementById('saAccMsg'); if (m2) { m2.textContent = `관리자 계정 "${r.name}" 을(를) 만들었습니다. 위 주소로 들어와 아이디·비밀번호로 로그인할 수 있습니다.`; m2.className = 'msg ok'; } } catch (e) { msg.textContent = e.message; msg.className = 'msg err'; } })); card.appendChild(row); msg.id = 'saAccMsg'; card.appendChild(msg); m.appendChild(card); const c2 = el('div', 'card'); c2.appendChild(el('h2', '', '관리자 권한 계정 목록')); // v1.3.0: 메일 시스템이 켜져 있으면 계정마다 "메일 사용" 을 바꿀 수 있다(같은 아이디·비밀번호로 웹메일 로그인) const mailOn = ov.mail && ov.mail.systemEnabled; if (mailOn) c2.appendChild(el('p', 'muted small', '"메일 사용" 을 켠 계정은 같은 아이디·비밀번호로 웹메일(아이디@사이트도메인)을 씁니다. 끈 계정은 관리용으로만 쓰입니다. 먼저 "메일" 탭에서 이 사이트의 웹메일을 켜야 합니다.')); await table('members', c2, { columns: ['name', 'loginId', 'email', 'role', ...(mailOn ? ['mailAddress'] : []), 'createdAt'], label: '회원', actions: mailOn ? [{ label: '메일 사용 켜기/끄기', fn: (it) => (it.loginId ? toggleMemberMail(it) : alert('사이트 관리자 계정(아이디가 있는 계정)만 메일을 쓸 수 있습니다')) }] : [] }); m.appendChild(c2); } if (!SITE) { document.body.innerHTML = '
잘못된 주소입니다.
'; } else boot(); // ---------- 메일 (v1.3.0) ---------- /** * 메일 탭: 이 사이트 웹메일 켜기/끄기, DNS 등록 안내·확인, 계정별 메일 사용. * 서버 API: GET/POST /api/site-admin/