/* ============================================================
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) => (
|
{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)}>사용자 추가
{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 (
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={
제출 상세
{fmtDateTime(s.at)}
}
footer={
총 에피소드 {s.items.length}개 · 페르소나 지정 {personaN}개
}>
{s.note && (
)}
{s.projectName}
{window.groupByMonthTheme(s.items).map(([key, rows]) => (
{key}
{rows.map((it, ri) =>
)}
))}
);
})()}
);
}
Object.assign(window, { AuditLog, UserManagement, EpisodeLog });