/* ============================================================ 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; /* ============================================================ AUDIT LOG ============================================================ */ function AuditLog({ user, toast, project }) { const [q, setQ] = useSA(''); const [action, setAction] = useSA(''); const [role, setRole] = useSA(''); const [page, setPage] = useSA(1); const { loading, error, data, reload } = useAsync(() => window.api.getAuditLogs({ q, action, role }), [q, action, role]); 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]); React.useEffect(() => { setPage(1); }, [q, action, role]); return (
{project ? '활동 로그' : '감사 로그'}
{project ? `'${project.name}' 운영 활동 이력 — 생성·수정·삭제·제출·상태 변경 기록.` : '모든 생성·수정·삭제·제출·상태 변경 이력을 기록합니다. (CNX 전체 조회)'}
setQ(e.target.value)} />
{(q || action || role) && { setQ(''); setAction(''); setRole(''); }}>필터 초기화} {total}건
{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}건
{Array.from({ length: pages }).map((_, i) => )}
)}
); } /* ============================================================ 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 { 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(); }; 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 ? 본인 계정 : toggleStatus(u)}>{u.status === 'active' ? '비활성화' : '활성화'} remove(u)}>삭제 }
)} {create && setCreate(false)} onSave={onCreate} />}
); } /* ---- 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 { loading, error, data, reload } = useAsync(() => window.api.getEpisodeLog(), []); const [selUser, setSelUser] = useSA(null); const [detail, setDetail] = useSA(null); // clicked submission 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]); return (
에피소드 로그
모든 프로젝트의 에피소드 제출 이력 — 담당자별로 누가 · 언제 · 어떤 테마의 어떤 에피소드를 제출했는지.
{loading &&
} {error && } {!loading && !error && (data || []).length === 0 && ( } title="제출 이력이 없습니다" desc="LGE가 에피소드를 제출하면 여기에 담당자별로 기록됩니다." /> )} {!loading && !error && (data || []).length > 0 && (
{subs.length === 0 &&
선택한 담당자의 제출 이력이 없습니다.
}
{subs.map((s) => { const first = s.items[0] || {}; const firstTitle = first.episodeTitle || (first.episodeId ? '(삭제된 에피소드)' : ''); return ( ); })}
)} {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}개 }> {s.note && (
소비자 메시지
“{s.note}”
)}
{s.projectName}
{window.groupByMonthTheme(s.items).map(([key, rows]) => (
{key}
{rows.map((it, ri) => )}
))}
); })()}
); } Object.assign(window, { AuditLog, UserManagement, EpisodeLog });