/* ============================================================ CNX admin — Audit log + User management ============================================================ */ const { useState: useSA, useMemo: useMA } = React; const ACTION_LABEL = { create: '생성', update: '수정', delete: '삭제', submit: '제출', status: '상태변경', 'todo.complete': '할 일 완료', 'todo.uncomplete': '할 일 취소' }; const PAGE_SIZE = 8; // 'YYYY-MM' 또는 'YYYY-MM-DD'에서 그 달의 마지막 일(28~31)을 구한다. function monthLastDay(ym) { return new Date(Number(ym.slice(0, 4)), Number(ym.slice(5, 7)), 0).getDate(); } // 게시판식 페이저: 현재 페이지가 속한 10개 블록의 번호들 + 이전/다음 블록 점프 대상. function pagerBlock(page, pages, BLOCK = 10) { const start = Math.floor((page - 1) / BLOCK) * BLOCK + 1; const end = Math.min(start + BLOCK - 1, pages); return { start, end, nums: Array.from({ length: end - start + 1 }, (_, i) => start + i), prev: Math.max(1, start - BLOCK), next: Math.min(pages, start + BLOCK), }; } /* ============================================================ AUDIT LOG ============================================================ */ function AuditLog({ user, toast, project }) { const isAdmin = !!user && user.role === 'ADMIN'; // 내보내기는 ADMIN만 const [q, setQ] = useSA(''); const [action, setAction] = useSA(''); const [role, setRole] = useSA(''); const [from, setFrom] = useSA(''); const [to, setTo] = useSA(''); const [page, setPage] = useSA(1); const { loading, error, data, reload } = useAsync(() => window.api.getAuditLogs({ q, action, role, from, to }), [q, action, role, from, to]); const total = data ? data.length : 0; const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const view = useMA(() => (data || []).slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [data, page]); const pb = pagerBlock(page, pages); // 현재 블록 번호들 + 이전/다음 점프 대상 React.useEffect(() => { setPage(1); }, [q, action, role, from, to]); const hasFilter = q || action || role || from || to; const resetFilters = () => { setQ(''); setAction(''); setRole(''); setFrom(''); setTo(''); }; // 월 선택값은 from/to에서 파생 — from/to가 한 달의 1일~말일과 정확히 맞을 때만 그 달 표시. const monthValue = useMA(() => { if (!from || !to) return ''; const ym = from.slice(0, 7); return (from === `${ym}-01` && to === `${ym}-${String(monthLastDay(ym)).padStart(2, '0')}`) ? ym : ''; }, [from, to]); // 월(YYYY-MM) 선택 → 그 달 1일~말일로 기간 세팅. 비우면 기간 해제. const onMonth = (v) => { if (!v) { setFrom(''); setTo(''); return; } setFrom(`${v}-01`); setTo(`${v}-${String(monthLastDay(v)).padStart(2, '0')}`); }; // 현재 필터가 적용된 전체 결과(현재 페이지가 아니라 data 전체)를 CSV로 내려받는다. const exportCsv = () => { const head = ['시각', '담당자', '역할', '액션', '엔티티', '대상', '이전값', '이후값']; const rows = [head, ...(data || []).map((a) => [ fmtDateTime(a.at), a.actor, a.role, ACTION_LABEL[a.action] || a.action, a.entity, a.target, a.before, a.after, ])]; window.api.downloadCsv((project ? '활동로그_' : '감사로그_') + new Date().toISOString().slice(0, 10) + '.csv', rows); }; return (
{project ? '활동 로그' : '감사 로그'}
{project ? `'${project.name}' 운영 활동 이력 — 생성·수정·삭제·제출·상태 변경 기록.` : '모든 생성·수정·삭제·제출·상태 변경 이력을 기록합니다. (CNX 전체 조회)'}
setQ(e.target.value)} />
onMonth(e.target.value)} aria-label="월 선택" title="월 선택" />
setFrom(e.target.value)} aria-label="시작일" /> ~ setTo(e.target.value)} aria-label="종료일" />
{hasFilter && 필터 초기화} {total}건 {isAdmin && } disabled={total === 0} onClick={exportCsv}>CSV 내보내기}
{loading &&
{[0, 1, 2, 3, 4].map((i) => )}
} {error && } {!loading && !error && total === 0 && } title="해당하는 로그가 없습니다" desc="필터 조건을 변경해 보세요." />} {!loading && !error && total > 0 && (
{view.map((a) => ( ))}
담당자액션엔티티대상 / 변경 내용시각
{a.actor}
{ACTION_LABEL[a.action] || a.action} {a.entity}
{a.target}
{a.before != null && {a.before}} {a.before != null && a.after != null && } {a.after != null && {a.after}} {a.before == null && a.after == null && }
{fmtDateTime(a.at)}
{(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} / {total}건
{pb.nums.map((p) => )}
)}
); } /* ============================================================ USER MANAGEMENT ============================================================ */ const ALL_ROLES = ['ADMIN', 'CNX', 'LGE']; function UserManagement({ user, toast }) { const [q, setQ] = useSA(''); const [role, setRole] = useSA(''); const [create, setCreate] = useSA(false); const [pwUser, setPwUser] = useSA(null); const { loading, error, data, reload } = useAsync(() => window.api.getUsers(), []); const view = useMA(() => (data || []).filter((u) => { if (role && u.role !== role) return false; if (q && !(u.name + u.email).toLowerCase().includes(q.toLowerCase())) return false; return true; }), [data, q, role]); const toggleStatus = async (u) => { try { await window.api.setUserStatus(u.id, u.status === 'active' ? 'inactive' : 'active'); toast(`${u.name} ${u.status === 'active' ? '비활성화' : '활성화'}됨`); reload(); } catch (e) { toast(e.message || '상태 변경 실패'); } }; const changeRole = async (u, newRole) => { if (newRole === u.role) return; try { await window.api.updateUserRole(u.id, newRole); toast(`${u.name} 역할을 ${newRole}(으)로 변경`); reload(); } catch (e) { toast(e.message || '역할 변경 실패'); reload(); } }; const remove = async (u) => { if (!window.confirm(`${u.name} 계정을 삭제할까요?`)) return; try { await window.api.deleteUser(u.id); toast(`${u.name} 삭제됨`); reload(); } catch (e) { toast(e.message || '삭제 실패'); } }; const onCreate = async (body) => { await window.api.createUser(body); setCreate(false); toast('사용자가 생성되었습니다'); reload(); }; const onSetPassword = async (password) => { const u = pwUser; await window.api.setUserPassword(u.id, password); setPwUser(null); toast(`${u.name} 비밀번호가 변경되었습니다`); }; return (
사용자 관리
팀원 계정과 초대 코드를 관리합니다. 마지막 관리자는 강등·삭제할 수 없습니다.
} onClick={() => setCreate(true)}>사용자 추가
setQ(e.target.value)} />
{view.length}명
{loading &&
{[0, 1, 2, 3].map((i) => )}
} {error && } {!loading && !error && view.length === 0 && } title="사용자가 없습니다" desc="조건에 맞는 사용자가 없습니다." />} {!loading && !error && view.length > 0 && (
{view.map((u) => { const self = u.id === user.id; return ( );})}
사용자역할상태등록일관리
{u.name}{self && (나)}
{u.email}
{self ? : } {u.status === 'active' ? '활성' : '비활성'} {u.createdAt} {self ? 본인 계정 setPwUser(u)}>비밀번호 변경 : setPwUser(u)}>비밀번호 변경 toggleStatus(u)}>{u.status === 'active' ? '비활성화' : '활성화'} remove(u)}>삭제 }
)} {create && setCreate(false)} onSave={onCreate} />} {pwUser && setPwUser(null)} onSave={onSetPassword} />}
); } function UserPasswordModal({ target, onClose, onSave }) { const [pw, setPw] = useSA(''); const [confirm, setConfirm] = useSA(''); const [busy, setBusy] = useSA(false); const [err, setErr] = useSA(''); const submit = async () => { if (pw.length < 4) return setErr('비밀번호는 4자 이상이어야 합니다.'); if (pw !== confirm) return setErr('비밀번호가 일치하지 않습니다.'); setBusy(true); setErr(''); try { await onSave(pw); } catch (e) { setBusy(false); setErr(e.message || '변경에 실패했습니다.'); } }; return ( 취소} onClick={submit}>변경}>
setPw(e.target.value)} placeholder="새 비밀번호" autoFocus />
setConfirm(e.target.value)} placeholder="다시 입력" />
{err &&
{err}
}
); } /* ---- Invite codes ---- */ function InviteCodes({ toast }) { const [count, setCount] = useSA(1); const [role, setRole] = useSA('LGE'); const [showUsed, setShowUsed] = useSA(false); const [busy, setBusy] = useSA(false); const { loading, error, data, reload } = useAsync(() => window.api.getInvites(showUsed), [showUsed]); const list = data || []; const counts = useMA(() => ({ total: list.length, pending: list.filter((i) => i.status === 'pending').length, used: list.filter((i) => i.status === 'used').length, }), [list]); const mint = async () => { setBusy(true); try { const made = await window.api.mintInvites(Number(count) || 1, role); toast(`${made.length}개 코드 발급됨`); reload(); } catch (e) { toast(e.message || '발급 실패'); } finally { setBusy(false); } }; const revoke = async (code) => { try { await window.api.revokeInvite(code); toast(`${code} 무효화됨`); reload(); } catch (e) { toast(e.message || '무효화 실패'); } }; const copy = (code) => { try { navigator.clipboard.writeText(code); toast(`${code} 복사됨`); } catch (e) { /* ignore */ } }; return (
초대 코드 발급 {counts.total} · pending {counts.pending} · used {counts.used}
setCount(e.target.value)} /> } disabled={busy} onClick={mint}>코드 생성
{loading &&
} {error &&
} {!loading && !error && list.length === 0 &&
발급된 코드가 없습니다. 위에서 새로 생성하세요.
} {!loading && !error && list.length > 0 && ( {list.map((i) => ( ))}
코드역할상태사용자관리
copy(i.code)} title="클릭하여 복사">{i.code} {i.status} {i.usedByEmail || '—'} {i.status === 'pending' ? revoke(i.code)}>무효화 : }
)}
); } function UserCreateModal({ onClose, onSave }) { const [f, setF] = useSA({ name: '', email: '', role: 'LGE', initPw: '' }); const set = (k) => (e) => setF((p) => ({ ...p, [k]: e.target.value })); const [busy, setBusy] = useSA(false); const [err, setErr] = useSA(''); const submit = async () => { if (!f.name.trim() || !f.email.trim()) return setErr('이름과 이메일을 입력하세요.'); if (!/.+@.+\..+/.test(f.email)) return setErr('올바른 이메일 형식이 아닙니다.'); setBusy(true); setErr(''); await onSave({ name: f.name.trim(), email: f.email.trim(), role: f.role, password: f.initPw }); }; return ( 취소} onClick={submit}>생성}>
{err &&
{err}
}
); } /* ============================================================ EPISODE LOG — cross-project submissions, browsed by submitter ============================================================ */ function EpisodeLog({ user, toast, navigate }) { const isAdmin = !!user && user.role === 'ADMIN'; // 삭제·내보내기는 ADMIN만 (CNX는 열람만) const { loading, error, data, reload } = useAsync(() => window.api.getEpisodeLog(), []); const [selUser, setSelUser] = useSA(null); const [detail, setDetail] = useSA(null); // clicked submission const [selContent, setSelContent] = useSA(''); // 콘텐츠(프로젝트) 일괄 삭제 대상 const [confirm, setConfirm] = useSA(null); // { kind:'one'|'content', sub?, projectId?, projectName?, count? } const [busy, setBusy] = useSA(false); const users = useMA(() => { const m = {}; (data || []).forEach((s) => { if (!m[s.submittedById]) m[s.submittedById] = { id: s.submittedById, name: s.actor, role: s.role, count: 0 }; m[s.submittedById].count++; }); return Object.values(m).sort((a, b) => b.count - a.count); }, [data]); React.useEffect(() => { if (selUser == null && users.length) setSelUser(users[0].id); }, [users]); const subs = useMA(() => (data || []).filter((s) => s.submittedById === selUser), [data, selUser]); // 콘텐츠(프로젝트) 단위 목록 — 일괄 삭제 드롭다운용 const contents = useMA(() => { const m = {}; (data || []).forEach((s) => { if (!m[s.projectId]) m[s.projectId] = { id: s.projectId, name: s.projectName, count: 0 }; m[s.projectId].count++; }); return Object.values(m).sort((a, b) => (a.name || '').localeCompare(b.name || '')); }, [data]); // 평면 CSV: 제출의 item마다 한 행. 콘텐츠 컬럼이 있어 엑셀에서 콘텐츠별 정렬·필터 가능. const exportCsv = () => { const head = ['콘텐츠', '담당자', '역할', '제출시각', '월', '테마', '에피소드', '페르소나', '소비자메시지']; const rows = [head]; (data || []).forEach((s) => { const items = s.items && s.items.length ? s.items : [null]; items.forEach((it) => { const title = it ? (it.episodeTitle || (it.episodeId ? '(삭제된 에피소드)' : '')) : ''; rows.push([s.projectName, s.actor, s.role, fmtDateTime(s.at), it ? it.monthLabel : '', it ? it.themeName : '', title, it ? it.personaName : '', s.note]); }); }); window.api.downloadCsv('에피소드제출로그_' + new Date().toISOString().slice(0, 10) + '.csv', rows); }; const runDelete = async () => { if (!confirm) return; setBusy(true); try { if (confirm.kind === 'one') await window.api.deleteSubmission(confirm.sub.id); else await window.api.deleteProjectSubmissions(confirm.projectId); await reload(); setConfirm(null); setDetail(null); setSelContent(''); toast('삭제되었습니다'); } catch (ex) { toast('오류: ' + ex.message); } finally { setBusy(false); } }; return (
에피소드 로그
모든 프로젝트의 에피소드 제출 이력 — 담당자별로 누가 · 언제 · 어떤 테마의 어떤 에피소드를 제출했는지.
{loading &&
} {error && } {!loading && !error && (data || []).length === 0 && ( } title="제출 이력이 없습니다" desc="LGE가 에피소드를 제출하면 여기에 담당자별로 기록됩니다." /> )} {!loading && !error && (data || []).length > 0 && (<>
{(data || []).length}건 제출 {isAdmin && (<> } disabled={!selContent} onClick={() => { const c = contents.find((x) => x.id === selContent); if (c) setConfirm({ kind: 'content', projectId: c.id, projectName: c.name, count: c.count }); }}>이 콘텐츠 로그 전체 삭제 } style={{ marginLeft: 'auto' }} onClick={exportCsv}>CSV 내보내기 )}
{subs.length === 0 &&
선택한 담당자의 제출 이력이 없습니다.
}
{subs.map((s) => { const first = s.items[0] || {}; const firstTitle = first.episodeTitle || (first.episodeId ? '(삭제된 에피소드)' : ''); return (
setDetail(s)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setDetail(s); } }}>
{s.projectName}
{fmtDateTime(s.at)}
{firstTitle &&
{firstTitle}{s.items.length > 1 ? ` 외 ${s.items.length - 1}건` : ''}
}
에피소드 {s.items.length}개 {isAdmin && ( )}
); })}
)} {detail && (() => { const s = detail; const personaN = s.items.filter((it) => it.personaName).length; return ( setDetail(null)} head={
제출 상세
{s.actor}
{fmtDateTime(s.at)}
} footer={
총 에피소드 {s.items.length}개 · 페르소나 지정 {personaN}개 {isAdmin && } onClick={() => setConfirm({ kind: 'one', sub: s })}>이 제출 삭제}
}> {s.note && (
소비자 메시지
“{s.note}”
)}
{s.projectName}
{window.groupByMonthTheme(s.items).map(([key, rows]) => (
{key}
{rows.map((it, ri) => )}
))}
); })()} {confirm && ( (busy ? null : setConfirm(null))} footer={<> setConfirm(null)}>취소 } onClick={runDelete}>삭제}>

{confirm.kind === 'content' ? `'${confirm.projectName}' 콘텐츠의 제출 로그 ${confirm.count}건을 삭제합니다.` : '이 제출 기록을 삭제합니다.'} {' '}삭제 후에도 감사 로그에는 기록이 남으며, 필요 시 복구할 수 있습니다.

)}
); } Object.assign(window, { AuditLog, UserManagement, EpisodeLog });