/* ============================================================
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 전체 조회)'}
{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}건
setPage(1)} title="처음">
setPage(pb.prev)} title="이전 10">
{pb.nums.map((p) => setPage(p)}>{p} )}
setPage(pb.next)} title="다음 10">
setPage(pages)} title="끝">
)}
);
}
/* ============================================================
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)}>사용자 추가
{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
?
: changeRole(u, e.target.value)}>{ALL_ROLES.map((r) => {r} )} }
{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 (
생성할 코드 수
setCount(e.target.value)} />
setRole(e.target.value)}>{ALL_ROLES.map((r) => {r} )}
} 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 && (<>
setSelContent(e.target.value)}>
콘텐츠 선택…
{contents.map((c) => {c.name} ({c.count}) )}
} 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 내보내기
>)}
{users.map((u) => (
setSelUser(u.id)}>
{u.count}
))}
{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 && (
{ e.stopPropagation(); setConfirm({ kind: 'one', sub: s }); }}>
)}
);
})}
>)}
{detail && (() => {
const s = detail;
const personaN = s.items.filter((it) => it.personaName).length;
return (
setDetail(null)}
head={
제출 상세
setDetail(null)}>
{fmtDateTime(s.at)}
}
footer={
총 에피소드 {s.items.length}개 · 페르소나 지정 {personaN}개
{isAdmin && } onClick={() => setConfirm({ kind: 'one', sub: s })}>이 제출 삭제}
}>
{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 });