/* ============================================================ Episode browse · detail (persona selector) · submit · history ============================================================ */ const { useState: useSE, useMemo: useME, useEffect: useEE } = React; /* selection helpers — selection = [{episodeId, personaLabel}] */ const selHas = (sel, id) => sel.some((s) => s.episodeId === id); const selPersonaLabel = (sel, id) => (sel.find((s) => s.episodeId === id) || {}).personaLabel || null; /* an episode's persona objects: prefer personas[] (4 fields), fall back to legacy persona1/2/3 */ const epPersonas = (ep) => { const arr = Array.isArray(ep && ep.personas) ? ep.personas : []; const fromNew = arr.filter((p) => p && String(p.title || '').trim()); if (fromNew.length) return fromNew.map((p) => ({ title: String(p.title).trim(), description: p.description || '', buildup: p.buildup || '', solution: p.solution || '' })); return [ep && ep.persona1, ep && ep.persona2, ep && ep.persona3] .filter((x) => x && String(x).trim()) .map((t) => ({ title: String(t).trim(), description: '', buildup: '', solution: '' })); }; /* just the labels (titles) — used where only the name is needed */ const epPersonaOptions = (ep) => epPersonas(ep).map((p) => p.title); /* ---- card list controls (shared by ② 카드 관리 + 라이브러리 피커) ---- */ // Split a free-text 설명 into bullet lines for card display. Honors explicit // line breaks and bullet chars; otherwise breaks on sentence ends (다././?/!), // so a single paragraph reads as scannable bullets. 문장 끝+공백을 개행으로 치환 후 // 분리 — 정규식 lookbehind는 구형 Safari/WebView에서 모듈 로드를 깨뜨려 쓰지 않는다. const descBullets = (text) => String(text || '') .split(/\n+/) .flatMap((line) => line.replace(/([.!?。])\s+/g, '$1\n').split('\n')) .map((s) => s.replace(/^[\s•·\-–—]+/, '').trim()) .filter(Boolean); // 설명을 "설명" 라벨 + 불릿 리스트로 — 카드 본문(EpisodeCard·라이브러리 피커 공용). // 불릿이 하나도 없으면(빈/공백뿐 설명) 라벨까지 통째로 미렌더. const EpDescBlock = ({ text }) => { const items = descBullets(text); return items.length === 0 ? null : (
설명
); }; // 카드 배치 여부 (단일 배치: 0 또는 1). 사용 유무 = placeCount > 0 const placeCount = (c) => (c.placement ? 1 : 0); // 배치 "이동" 판정 + 확인 다이얼로그 (인라인/모달/라이브러리 피커 공용). // 기존 배치(prev)가 있고 다른 테마(next)로 바뀌면 이동. count>1 = 일괄(라이브러리 피커). const isMove = (prevThemeId, nextThemeId) => !!prevThemeId && !!nextThemeId && prevThemeId !== nextThemeId; const confirmMove = (count = 1) => window.confirm(count > 1 ? `선택한 카드 중 ${count}개는 다른 곳에서 이동됩니다. 진행할까요?` : '이 카드를 다른 테마로 이동할까요?'); // ko-collated comparator; String() guards non-string values (JSON columns). const koCompare = (a, b) => String(a).localeCompare(String(b), 'ko'); // union of every product across the given cards, ko-collated. const cardProductOptions = (cards) => { const s = new Set(); (cards || []).forEach((c) => (c.products || []).forEach((p) => p && s.add(p))); return Array.from(s).sort(koCompare); }; // does a card pass the {q, product, usage} filter? (search over title·설명·텐션·제품·페르소나) const matchCard = (c, { q, product, usage }) => { if (product && !(c.products || []).includes(product)) return false; if (usage === 'used' && placeCount(c) === 0) return false; if (usage === 'unused' && placeCount(c) > 0) return false; const needle = (q || '').trim().toLowerCase(); if (!needle) return true; const hay = [c.title, c.description, c.tension, ...(c.products || []), ...epPersonaOptions(c)] .filter(Boolean).join(' ').toLowerCase(); return hay.includes(needle); }; // 콘텐츠(project)·월(month)·테마(theme) 등록 위치 필터. // project '' = 전체, '__none__' = 미등록(배치 없음), 그 외 = 해당 콘텐츠에 배치된 카드. const PROJECT_NONE = '__none__'; // 필터 줄의 셀렉트 셀 공통 스타일 (균등폭 한 줄) const FILTER_CELL = { flex: '1 1 0', minWidth: 120 }; const matchPlacement = (c, { project, month, theme }) => { if (project === PROJECT_NONE) return !c.placement; if (!project && !month && !theme) return true; const p = c.placement; return !!p && (!project || p.projectId === project) && (!month || p.monthId === month) && (!theme || p.themeId === theme); }; // search box + 제품 select + 전체/사용중/미사용 segment. `children` = extra trailing // control (e.g. the manager's sort select); picker passes none. // Row 1 = 검색(전폭). Row 2 = 제품·(사용여부)·등록위치·정렬 셀들을 균등폭으로 한 줄에. // `children`(매니저의 콘텐츠/월/테마/정렬 셀렉트)는 각자 flex:1 1 0 로 같은 폭을 차지하도록 둔다. function CardFilterBar({ q, setQ, product, setProduct, productOptions, usage, setUsage, showUsage = true, showProduct = true, mb = 12, children }) { return (
setQ(e.target.value)} placeholder="제목·설명·텐션·제품·페르소나 검색" style={{ paddingLeft: 30, paddingRight: q ? 28 : 12, width: '100%' }} /> {q && }
{showProduct && ( )} {showUsage && (
)} {children}
); } // "조건에 맞는 카드가 없습니다" + 필터 초기화 — shown when a filter hides everything. function CardFilterEmpty({ onReset }) { return } title="조건에 맞는 카드가 없습니다" desc="검색어나 필터를 바꿔보세요." action={필터 초기화} />; } /* ============================================================ EPISODE BROWSE (view / select / submit — no authoring) ============================================================ */ function EpisodeBrowse({ project, month, user, navigate, toast }) { const { loading, error, data, reload } = useAsync(() => window.api.getContent(project.id), [project.id]); // month tab state — keyed by month.id; guard so tab switching works after reload const [curMonthId, setCurMonthId] = useSE(null); useEE(() => { if (data && (!curMonthId || !data.months.some((m) => m.id === curMonthId))) { setCurMonthId(data.months[0]?.id ?? null); } }, [data]); const [sel, setSel] = useSE([]); // session-only persona choices for not-yet-selected episodes: shown (blue) on // the card without selecting/submitting the episode. Carried into the // selection if/when the episode is later checked. const [personaDraft, setPersonaDraft] = useSE({}); const [detail, setDetail] = useSE(null); // episode being viewed const [submitOpen, setSubmitOpen] = useSE(false); const [submitting, setSubmitting] = useSE(false); useEE(() => { if (data) setSel(data.currentSelection || []); }, [data]); // per-month quota const cm = data ? data.months.find((m) => m.id === curMonthId) : null; const monthCap = cm ? (cm.maxSelections || 0) : 0; // build episode→monthId map from themes const epMonth = useME(() => { const map = {}; if (data) data.themes.forEach((t) => t.episodes.forEach((ep) => { map[ep.id] = t.monthId; })); return map; }, [data]); const monthSelCount = sel.filter((s) => epMonth[s.episodeId] === curMonthId).length; const count = sel.length; const allPersona = sel.every((s) => s.personaLabel); const persist = (next) => { setSel(next); window.api.setSelection(project.id, null, next); }; const toggleSelect = (ep) => { if (ep.status === 'paused') return; if (selHas(sel, ep.id)) { persist(sel.filter((s) => s.episodeId !== ep.id)); } else { // enforce the per-month cap (0 = unlimited); mirrors backend MONTH_QUOTA if (monthCap > 0 && monthSelCount >= monthCap) { toast(`${cm?.label ?? '이 달'}은 최대 ${monthCap}개까지 선택할 수 있습니다`); return; } persist([...sel, { episodeId: ep.id, personaLabel: personaDraft[ep.id] ?? null }]); } }; const assignPersona = (epId, personaLabel) => { // Picking a persona never selects the episode — selection goes through // toggleSelect, which enforces the month cap (mirrors backend MONTH_QUOTA). // Always remember the choice as a session draft (so it shows on the card); // if the episode is already selected, also persist it onto the snapshot. setPersonaDraft((d) => ({ ...d, [epId]: personaLabel })); if (selHas(sel, epId)) persist(sel.map((s) => (s.episodeId === epId ? { ...s, personaLabel } : s))); }; const doSubmit = async (note) => { setSubmitting(true); await window.api.submit(project.id, null, { items: sel, note }); setSubmitting(false); setSubmitOpen(false); toast(`에피소드 ${sel.length}개가 제출되었습니다`); navigate({ name: 'project', projectId: project.id, tab: user.role === 'LGE' ? 'episodes' : 'history' }); }; // flat episode map (for drawer/submit-modal episode lookups) const epById = useME(() => flatEpisodes(data ? data.themes : []), [data]); // themes for the selected month tab, showing ONLY 운영가이드 표기-ON episodes // (status !== 'paused'). Themes left with no visible episode are dropped so the // LGE guide never shows paused/hidden content; if nothing is left the month // renders the empty-state notice below. const visibleThemes = data ? data.themes .filter((t) => t.monthId === curMonthId) .map((t) => ({ ...t, episodes: t.episodes.filter((ep) => ep.status !== 'paused') })) .filter((t) => t.episodes.length > 0) : []; return (
{/* sticky submit bar — hidden when the selected month has no 표기-ON content */} {visibleThemes.length > 0 && (
{monthCap > 0 ? : }
에피소드 선택
{monthCap > 0 ?
= 1 ? 'ok' : 'under'}`}> 에피소드를 {monthCap}개 선택해주세요. (현재 {monthSelCount}개)
:
= 1 ? 'ok' : 'under'}`}> {count}개 선택됨{count >= 1 ? ' · 제출 가능' : ' · 1개 이상 선택'}
}
{!allPersona && count >= 1 && 페르소나 미지정 {sel.filter((s) => !s.personaLabel).length}개} {user.role !== 'LGE' && } className="hide-mobile" onClick={() => navigate({ name: 'project', projectId: project.id, tab: 'history' })}>제출 히스토리} } disabled={count < 1} onClick={() => setSubmitOpen(true)}> 제출{count > 0 ? ` (${count})` : ''}
)} {/* Month tabs */} {!loading && !error && data && data.months.length > 0 && (
{data.months.map((m) => ( ))}
)} {loading && [0, 1].map((i) => (
{[0, 1, 2].map((j) =>
)}
))} {error && } {!loading && !error && visibleThemes.length === 0 && ( } title="표시할 콘텐츠가 없습니다" desc="이 달에 운영가이드 표기(ON)된 에피소드가 아직 없습니다." /> )} {!loading && !error && visibleThemes.map((t, ti) => (
Theme {String(ti + 1).padStart(2, '0')}
{t.name}
{t.subtitle}
{t.episodes.map((ep, ei) => { const ec = ep; return ( toggleSelect(ec)} onOpen={() => setDetail({ ...ec, themeId: t.id, themeName: t.name })} /> ); })}
))} {/* DETAIL DRAWER — no onEdit affordance */} {detail && ( setDetail(null)} onToggleSelect={() => toggleSelect(detail)} onAssignPersona={(label) => assignPersona(detail.id, label)} onEdit={null} navigate={navigate} /> )} {/* SUBMIT MODAL */} {submitOpen && ( setSubmitOpen(false)} onConfirm={doSubmit} /> )}
); } /* ============================================================ CONTENT DETAIL (month tabs / themes / pool / edit modals — extracted from EpisodeAdmin so EpisodeAdmin stays a thin shell) ============================================================ */ function ContentDetail({ projectId, user, toast }) { const { loading, error, data, reload } = useAsync( () => projectId ? window.api.getContent(projectId) : Promise.resolve(null), [projectId] ); // month tab state — keyed by month.id; guard so tab switching works after reload const [curMonthId, setCurMonthId] = useSE(null); useEE(() => { if (data && (!curMonthId || !data.months.some((m) => m.id === curMonthId))) { setCurMonthId(data.months[0]?.id ?? null); } }, [data]); const [editEp, setEditEp] = useSE(null); // {themeId, episode} — edit an already-placed card const [pickTheme, setPickTheme] = useSE(null); // {theme} — library picker target const [editTheme, setEditTheme] = useSE(null); // {theme|null, defaultMonthId?} const [editMonth, setEditMonth] = useSE(null); // {month|null} for month create/edit const [confirm, setConfirm] = useSE(null); const [importRef, setImportRef] = useSE(null); // file input ref /* CNX mutations */ // Editing a placed card edits the shared library card (reflected everywhere it's linked). const saveEpisode = async (themeId, body) => { await window.api.updateEpisode(body.id, { title: body.title, description: body.description, tension: body.tension, products: body.products, voc: body.voc, rtb: body.rtb, personas: body.personas, }); if (body.placeChanged) await window.api.setPlacement(body.id, body.placeThemeId); setEditEp(null); toast('에피소드가 수정되었습니다'); reload(); }; const epStatus = async (ep, status) => { await window.api.updateEpisode(ep.id, { status }); toast(status === 'paused' ? '에피소드 일시중지됨' : '에피소드 활성화됨'); reload(); }; // 단일 배치: 테마에서 제거 = 배치 해제(미배치). 카드는 라이브러리에 남는다. const unlinkEpisode = async () => { const { episode } = confirm.ep; setConfirm(null); await window.api.setPlacement(episode.id, null); toast('테마에서 제거됨'); reload(); }; // 단일 배치: 카드를 이 테마로 배치(이미 다른 테마면 이동). setPlacement가 set/move를 모두 처리. const linkEpisodes = async (themeId, episodeIds) => { for (const eid of episodeIds) await window.api.setPlacement(eid, themeId); setPickTheme(null); toast(`${episodeIds.length}개 카드가 추가되었습니다`); reload(); }; const saveTheme = async (body) => { if (body.id) await window.api.updateTheme(body.id, body); else await window.api.createTheme(projectId, body); setEditTheme(null); toast(body.id ? '테마 수정됨' : '테마 추가됨'); reload(); }; const delTheme = async () => { const t = confirm.theme; setConfirm(null); await window.api.deleteTheme(t.id); toast('테마 삭제됨'); reload(); }; const saveMonth = async (body) => { try { if (body.id) { await window.api.updateMonth(body.id, { label: body.label, maxSelections: body.maxSelections, sortOrder: body.sortOrder }); toast('월 수정됨'); } else { const created = await window.api.createMonth(projectId, { label: body.label, maxSelections: body.maxSelections, sortOrder: body.sortOrder ?? 0 }); if (created && created.id) setCurMonthId(created.id); toast('월 추가됨'); } setEditMonth(null); reload(); } catch (ex) { toast('오류: ' + ex.message); } }; const delMonth = async () => { const m = confirm.month; setConfirm(null); try { await window.api.deleteMonth(m.id); toast('월 삭제됨'); reload(); } catch (ex) { toast('오류: ' + ex.message); } }; const handleImport = async (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; e.target.value = ''; try { const r = await window.api.importEpisodes(file); toast(`${r.imported}개 생성` + (r.skipped && r.skipped.length ? `, ${r.skipped.length}개 스킵` : '')); reload(); } catch (ex) { toast('오류: ' + ex.message); } }; const visibleThemes = data ? data.themes.filter((t) => t.monthId === curMonthId) : []; return (
{/* Month tabs + 월 추가/편집 */} {!loading && !error && data && (
{data.months.length === 0 && 월 없음 — 아래에서 추가하세요} {data.months.map((m) => ( ))}
{curMonthId && (() => { const curMonth = data.months.find((m) => m.id === curMonthId); return curMonth ? ( <> ) : null; })()} } onClick={() => setEditMonth({ month: null })}>월 추가
)} {/* CNX toolbar: add-theme + import (only when a month is selected) */} {!loading && !error && data && curMonthId && (
{visibleThemes.length}개 테마
} onClick={() => setEditTheme({ theme: null, defaultMonthId: curMonthId })}>테마 추가 window.api.exportEpisodeTemplate().catch((ex) => toast('오류: ' + ex.message))}>양식 다운로드 } onClick={() => importRef && importRef.click()}>엑셀 업로드 setImportRef(el)} onChange={handleImport} />
)} {loading && [0, 1].map((i) => (
{[0, 1, 2].map((j) =>
)}
))} {error && } {/* No month state */} {!loading && !error && data && data.months.length === 0 && ( } title="월이 없습니다" desc="먼저 월을 추가하고, 테마와 에피소드를 등록하세요." action={} onClick={() => setEditMonth({ month: null })}>월 추가} /> )} {!loading && !error && data && curMonthId && visibleThemes.length === 0 && data.months.length > 0 && ( } title="등록된 테마가 없습니다" desc="테마를 추가하고 에피소드를 등록하세요." action={} onClick={() => setEditTheme({ theme: null, defaultMonthId: curMonthId })}>테마 추가} /> )} {!loading && !error && visibleThemes.map((t, ti) => (
Theme {String(ti + 1).padStart(2, '0')}
{t.name}
{t.subtitle}
} onClick={() => setPickTheme({ theme: t })}>에피소드 추가 }>
{t.episodes.map((ep, ei) => { const ec = ep; return ( {}} onOpen={() => {}} onEdit={() => setEditEp({ themeId: t.id, episode: ep, placement: { themeId: t.id } })} onPause={() => epStatus(ec, ec.status === 'paused' ? 'active' : 'paused')} onDelete={() => setConfirm({ ep: { themeId: t.id, episode: ec } })} /> ); })} {t.episodes.length === 0 &&
에피소드가 없습니다.
}
))} {/* EDIT MODALS */} {editEp && setEditEp(null)} onSave={(body) => saveEpisode(editEp.themeId, body)} />} {pickTheme && ( e.id)} onClose={() => setPickTheme(null)} onConfirm={(ids) => linkEpisodes(pickTheme.theme.id, ids)} /> )} {editTheme && ( setEditTheme(null)} onSave={saveTheme} /> )} {editMonth && setEditMonth(null)} onSave={saveMonth} />} {confirm && ( setConfirm(null)} footer={<> setConfirm(null)}>취소} onClick={confirm.month ? delMonth : confirm.theme ? delTheme : unlinkEpisode}>{confirm.ep ? '제거' : '삭제'}}>

{confirm.month ? '이 월과 포함된 모든 테마가 삭제됩니다.' : confirm.theme ? '이 테마와 포함된 모든 배치가 해제됩니다.' : '이 카드를 테마에서 제거합니다. 카드는 라이브러리에 남습니다.'}

)}
); } /* ============================================================ LIBRARY PICKER (① 테마에 라이브러리 카드를 링크로 추가) ============================================================ */ function LibraryPickerModal({ theme, linkedIds, onClose, onConfirm }) { const { loading, error, data, reload } = useAsync(() => window.api.getEpisodes(), []); const [picked, setPicked] = useSE([]); // episodeId[] const [busy, setBusy] = useSE(false); // list controls — mirror ② 카드 관리: search · product · usage const [q, setQ] = useSE(''); const [product, setProduct] = useSE(''); const [usage, setUsage] = useSE('all'); // 'all' | 'used' | 'unused' const available = useME(() => { const linked = new Set(linkedIds || []); return (data || []).filter((e) => !linked.has(e.id)); }, [data, linkedIds]); const productOptions = useME(() => cardProductOptions(available), [available]); const shown = useME(() => available.filter((c) => matchCard(c, { q, product, usage })), [available, q, product, usage]); const filtered = !!(q.trim() || product || usage !== 'all'); const resetFilters = () => { setQ(''); setProduct(''); setUsage('all'); }; const toggle = (id) => setPicked((p) => (p.includes(id) ? p.filter((x) => x !== id) : [...p, id])); // 단일 배치: 이 피커는 이 테마에 없는 카드만 보여주므로, placement 있는 picked는 모두 "이동". const moveCount = useME(() => (data || []).filter((c) => picked.includes(c.id) && c.placement).length, [data, picked]); const confirmAdd = async () => { if (moveCount > 0 && !confirmMove(moveCount)) return; setBusy(true); try { await onConfirm(picked); } finally { setBusy(false); } }; return ( 취소} onClick={confirmAdd}>{picked.length > 0 ? `${picked.length}개 추가` : '추가'}}> {loading &&
{[0,1,2].map((i) => )}
} {error && } {!loading && !error && available.length === 0 && ( } title="추가할 카드가 없습니다" desc="라이브러리의 모든 카드가 이미 이 테마에 배치되어 있거나, 라이브러리가 비어 있습니다. ② 에피소드 카드 관리에서 카드를 추가하세요." /> )} {!loading && !error && available.length > 0 && ( <>
{filtered ? `${shown.length} / ${available.length}개 표시` : `${available.length}개 추가 가능`} · 선택 {picked.length}개 {moveCount > 0 && · {moveCount}개 이동}
{shown.length === 0 ? : (
{shown.map((ec) => { const on = picked.includes(ec.id); return (
toggle(ec.id)}> {ec.placement && 이동}
{ec.title}
{(ec.products || []).length > 0 &&
{(ec.products || []).map((p, j) => {p})}
}
); })}
)} )}
); } /* ============================================================ CONTENT CARD (single card; lazy-loads its own counts) ============================================================ */ function ContentCard({ project, onOpen, onEdit, onDelete }) { const { data } = useAsync(() => window.api.getContent(project.id), [project.id]); const monthsN = data ? data.months.length : null; const themesN = data ? data.themes.length : null; const epN = data ? data.themes.reduce((a, t) => a + ((t.episodes && t.episodes.length) || 0), 0) : null; // Use periodStart — badge is start-based (due date is optional) const hasPeriod = !!project.periodStart; const fmtDate = (d) => d ? String(d).slice(0, 10) : ''; const periodTxt = hasPeriod ? `${fmtDate(project.periodStart)}${project.periodEnd ? ` – ${fmtDate(project.periodEnd)}` : ' ~ 미정'}` : '기간 미설정'; const isLive = project.status === 'active'; const Badge = ({ on, children }) => ( {children} ); return (
{project.name} {isLive && 운영가이드 노출중}
{periodTxt}
{hasPeriod ? '기간 설정됨' : '기간 미설정'} 0}>월 {monthsN == null ? '·' : monthsN} 0}>테마 {themesN == null ? '·' : themesN} 0}>에피소드 {epN == null ? '·' : epN}
{(onEdit || onDelete) && (
{onEdit && ( )} {onDelete && ( )}
)}
); } /* ============================================================ CONTENT GRID (project card grid + 콘텐츠 추가) ============================================================ */ function ContentGrid({ onOpen, toast }) { const projects = useAsync(() => window.api.getProjects(), []); const [createProject, setCreateProject] = useSE(false); const [editProject, setEditProject] = useSE(null); const [delProject, setDelProject] = useSE(null); const [delBusy, setDelBusy] = useSE(false); return (
{projects.loading &&
{[0,1,2].map((i) => )}
} {!projects.loading && (
{(projects.data || []).map((p) => onOpen(p.id)} onEdit={(p) => setEditProject(p)} onDelete={(p) => setDelProject(p)} />)}
)} {createProject && setCreateProject(false)} onSave={async (body) => { try { const p = await window.api.createProject(body); await projects.reload(); setCreateProject(false); toast('콘텐츠가 추가되었습니다'); if (p && p.id) onOpen(p.id); } catch (ex) { toast('오류: ' + ex.message); } }} />} {editProject && setEditProject(null)} onSave={async (body) => { try { const visChanged = (editProject.status === 'active') !== body.visible; await window.api.updateProject(editProject.id, { name: body.name, description: body.description, periodStart: body.periodStart, periodEnd: body.periodEnd }); await window.api.setProjectVisible(editProject.id, body.visible); await projects.reload(); setEditProject(null); toast('콘텐츠가 수정되었습니다'); // 운영중(표기) 변경은 운영가이드 뷰어의 프로젝트 선택(app.jsx projQ, [user] 캐시)에 // 영향을 주므로 하드 리프레시로 바로 반영시킨다 (토스트가 잠깐 보인 뒤 리로드). if (visChanged) setTimeout(() => window.location.reload(), 500); } catch (ex) { toast('오류: ' + ex.message); } }} />} {delProject && ( setDelProject(null)} footer={<> setDelProject(null)}>취소} onClick={async () => { setDelBusy(true); try { await window.api.deleteProject(delProject.id); await projects.reload(); setDelProject(null); toast('콘텐츠가 삭제되었습니다'); } catch (ex) { toast('오류: ' + ex.message); } finally { setDelBusy(false); } }}>삭제}>

이 콘텐츠와 포함된 월·테마·배치가 삭제됩니다. (에피소드 카드 자체는 라이브러리에 남습니다.)

)}
); } /* 공용 배치 피커 — 콘텐츠→월→테마 연쇄 select. value = themeId|null, onChange(themeId|null). getPlacementTargets() 트리: [{ projectId, projectName, months:[{monthId,label,themes:[{themeId,name}]}], themesNoMonth:[{themeId,name}] }] value(themeId)로부터 현재 project/month를 역추적해 select를 프리셋한다. */ function PlacementPicker({ value, onChange, compact, targets: targetsProp }) { // targets prop이 있으면(매니저 그리드에서 1회 fetch 후 주입) 내부 fetch를 건너뛴다. // 없으면(모달 단일 인스턴스) 기존대로 자체 fetch. const self = useAsync(() => (targetsProp ? Promise.resolve(targetsProp) : window.api.getPlacementTargets()), [targetsProp]); const data = targetsProp || self.data; const targets = data || []; // themeId → { projectId, monthId } 역인덱스 (value 프리셋 + 변경시 정합성 검증용) const themeIndex = useME(() => { const idx = {}; targets.forEach((p) => { (p.months || []).forEach((m) => (m.themes || []).forEach((t) => { idx[t.themeId] = { projectId: p.projectId, monthId: m.monthId }; })); (p.themesNoMonth || []).forEach((t) => { idx[t.themeId] = { projectId: p.projectId, monthId: null }; }); }); return idx; }, [data]); const [proj, setProj] = useSE(''); const [month, setMonth] = useSE(''); // '' = (월 없음/전체); themesNoMonth는 월 미선택일 때 노출 // value(themeId) 또는 targets 로드 시 select를 역추적해 동기화. useEE(() => { if (value && themeIndex[value]) { setProj(themeIndex[value].projectId); setMonth(themeIndex[value].monthId || ''); } else if (!value) { setProj(''); setMonth(''); } }, [value, data]); const curProject = useME(() => targets.find((p) => p.projectId === proj) || null, [targets, proj]); const monthsOf = curProject ? (curProject.months || []) : []; // 테마 옵션: 월 선택 시 그 월의 테마, 월 미선택 시 프로젝트의 무월(themesNoMonth) 테마. const themesOf = useME(() => { if (!curProject) return []; if (month) { const m = monthsOf.find((mm) => mm.monthId === month); return m ? (m.themes || []) : []; } return curProject.themesNoMonth || []; }, [curProject, month]); // 콘텐츠/월 변경은 "탐색"일 뿐 커밋하지 않는다(value 유지). 실제 배치는 테마 선택에서만 // 커밋된다. 단 "미배치"(빈 콘텐츠) 선택은 즉시 null 커밋(테마→미배치 경로). // 이렇게 해야 이미 배치된 카드를 다른 콘텐츠/월로 옮길 때 드롭다운이 미배치로 튀지 않는다. const onProj = (v) => { setProj(v); setMonth(''); if (v === '') onChange(null); }; const onMonth = (v) => { setMonth(v); }; const onTheme = (v) => onChange(v || null); const cellW = compact ? { flex: '1 1 0', minWidth: 0 } : { width: '100%' }; const wrap = compact ? { display: 'flex', gap: 6, flexWrap: 'wrap' } : { display: 'grid', gridTemplateColumns: '1fr', gap: 6 }; return (
); } /* Where a library card is placed (단일 배치) — shown on each manager card. placement = {themeId, themeName, projectId, projectName, monthId, monthLabel} | null */ function PlacementInfo({ placement }) { // faint hairline above; compact "등록됨" label; 콘텐츠/테마 on separate label-rows const hdr = { fontSize: 10, fontWeight: 800, letterSpacing: '.04em', textTransform: 'uppercase' }; return (
{!placement ?
미등록
: ( <>
등록됨
콘텐츠{placement.projectName}
테마{placement.monthLabel ? `${placement.monthLabel} ` : ''}{placement.themeName}
)}
); } /* ============================================================ EPISODE CARD MANAGER (② 에피소드 카드 관리 — global card pool) ============================================================ */ function EpisodeCardManager({ user, toast }) { const { loading, error, data, reload } = useAsync(() => window.api.getEpisodes(), []); // 배치 대상(콘텐츠→월→테마) 트리는 카드마다 동일하므로 매니저에서 1회만 fetch해 // 각 인라인 PlacementPicker에 주입한다 (카드 N개 → fetch N회 방지). const targetsQ = useAsync(() => window.api.getPlacementTargets(), []); const [editEp, setEditEp] = useSE(null); // { episode: ep|null } const [confirm, setConfirm] = useSE(null); // { episode } const [importRef, setImportRef] = useSE(null); // list controls: text search · product filter · sort const [q, setQ] = useSE(''); const [product, setProduct] = useSE(''); // '' = 전체 제품 const [sort, setSort] = useSE('default'); // 'default' | 'title' | 'usage' // 등록 위치 필터 (연쇄): 콘텐츠(projectId | '' 전체 | '__none__' 미등록) → 월(monthId) → 테마(themeId) const [fProject, setFProject] = useSE(''); const [fMonth, setFMonth] = useSE(''); const [fTheme, setFTheme] = useSE(''); // 다중 선택 (일괄 삭제용) — Set const [selected, setSelected] = useSE(() => new Set()); const [bulkConfirm, setBulkConfirm] = useSE(false); const allCards = data || []; const productOptions = useME(() => cardProductOptions(allCards), [allCards]); const usedN = useME(() => allCards.filter((c) => placeCount(c) > 0).length, [allCards]); // distinct 콘텐츠/월/테마 옵션 (모든 카드의 단일 placement에서). 월·테마는 상위 선택으로 좁힌다. const placementOpts = useME(() => { const projects = new Map(); const months = new Map(); const themes = new Map(); allCards.forEach((c) => { const p = c.placement; if (!p) return; if (p.projectId) projects.set(p.projectId, p.projectName || p.projectId); if (p.monthId) months.set(p.monthId, { id: p.monthId, label: p.monthLabel || '월 미지정', projectId: p.projectId }); if (p.themeId) themes.set(p.themeId, { id: p.themeId, name: p.themeName || p.themeId, projectId: p.projectId, monthId: p.monthId }); }); return { projects: Array.from(projects, ([id, name]) => ({ id, name })).sort((a, b) => koCompare(a.name, b.name)), months: Array.from(months.values()), themes: Array.from(themes.values()), }; }, [allCards]); const monthOpts = useME(() => placementOpts.months.filter((m) => !fProject || m.projectId === fProject), [placementOpts, fProject]); const themeOpts = useME(() => placementOpts.themes.filter((t) => (!fProject || t.projectId === fProject) && (!fMonth || t.monthId === fMonth)), [placementOpts, fProject, fMonth]); const cards = useME(() => { let out = allCards.filter((c) => matchCard(c, { q, product }) && matchPlacement(c, { project: fProject, month: fMonth, theme: fTheme })); if (sort === 'title') out = [...out].sort((a, b) => koCompare(a.title, b.title)); else if (sort === 'usage') out = [...out].sort((a, b) => placeCount(b) - placeCount(a)); return out; }, [allCards, q, product, sort, fProject, fMonth, fTheme]); const filtered = !!(q.trim() || product || fProject || fMonth || fTheme); const resetFilters = () => { setQ(''); setProduct(''); setFProject(''); setFMonth(''); setFTheme(''); }; const onProject = (v) => { setFProject(v); setFMonth(''); setFTheme(''); }; const onMonth = (v) => { setFMonth(v); setFTheme(''); }; // reload 후 사라진 옵션을 가리키는 필터 값 정리. (disabled select에 stale 값이 남아 // 빈 목록으로 막히는 것 방지 — '__none__'=미등록은 데이터와 무관하게 항상 유효.) useEE(() => { if (fProject && fProject !== PROJECT_NONE && !placementOpts.projects.some((p) => p.id === fProject)) { setFProject(''); setFMonth(''); setFTheme(''); return; } if (fMonth && !monthOpts.some((m) => m.id === fMonth)) { setFMonth(''); setFTheme(''); return; } if (fTheme && !themeOpts.some((t) => t.id === fTheme)) setFTheme(''); }, [placementOpts, monthOpts, themeOpts]); // selection: 전체 선택 = 현재 필터된 카드 전체. data reload 시 현존 id로 prune. useEE(() => { setSelected((prev) => { const live = new Set(allCards.map((c) => c.id)); const next = new Set(); prev.forEach((id) => live.has(id) && next.add(id)); return next.size === prev.size ? prev : next; }); }, [data]); const visibleIds = useME(() => cards.map((c) => c.id), [cards]); const allSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id)); const toggleOne = (id) => setSelected((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; }); const toggleAll = () => setSelected((prev) => { const n = new Set(prev); if (allSelected) visibleIds.forEach((id) => n.delete(id)); else visibleIds.forEach((id) => n.add(id)); return n; }); // 일괄 동작은 "현재 보이는 + 선택된" 카드만 대상 (필터로 가려진 선택은 건드리지 않음 — // allSelected/toggleAll과 동일 범위, 안 보이는 카드를 실수로 삭제하지 않게). const selectedCards = useME(() => cards.filter((c) => selected.has(c.id)), [cards, selected]); const selCount = selectedCards.length; const selRegistered = useME(() => selectedCards.filter((c) => placeCount(c) > 0).length, [selectedCards]); const bulkDelete = async () => { const ids = selectedCards.map((c) => c.id); setBulkConfirm(false); const results = await Promise.allSettled(ids.map((id) => window.api.deleteEpisode(id))); const failed = results.filter((r) => r.status === 'rejected').length; setSelected(new Set()); reload(); toast(failed ? `${ids.length - failed}개 삭제됨, ${failed}개 실패` : `${ids.length}개 카드 삭제됨`); }; const saveEpisode = async (body) => { await window.api.updateEpisode(body.id, { title: body.title, description: body.description, tension: body.tension, products: body.products, voc: body.voc, rtb: body.rtb, personas: body.personas }); if (body.placeChanged) await window.api.setPlacement(body.id, body.placeThemeId); setEditEp(null); toast('에피소드가 수정되었습니다'); reload(); }; const createCard = async (body) => { const created = await window.api.createEpisode(body); if (body.placeChanged && created && created.id) await window.api.setPlacement(created.id, body.placeThemeId); setEditEp(null); toast('카드가 추가되었습니다'); reload(); }; const delEpisode = async () => { const e = confirm.episode; setConfirm(null); await window.api.deleteEpisode(e.id); toast('에피소드 삭제됨'); reload(); }; const handleImport = async (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; e.target.value = ''; try { const r = await window.api.importEpisodes(file); toast(`${r.imported}개 생성` + (r.skipped && r.skipped.length ? `, ${r.skipped.length}개 스킵` : '')); reload(); } catch (ex) { toast('오류: ' + ex.message); } }; // 카드 인라인 배치 변경. 미등록→테마/테마→미배치는 즉시; 다른 테마로 이동이면 confirm. const inlinePlace = async (card, themeId) => { const prev = card.placement ? card.placement.themeId : null; if (themeId === prev) return; if (isMove(prev, themeId) && !confirmMove()) return; try { await window.api.setPlacement(card.id, themeId); toast(themeId ? '배치되었습니다' : '미배치로 변경됨'); reload(); } catch (ex) { toast('오류: ' + ex.message); } }; return (
{filtered ? `${cards.length} / ${allCards.length}개 카드` : `${allCards.length}개 카드`} · 글로벌 라이브러리 · 등록됨 {usedN}개
} onClick={() => setEditEp({ episode: null })}>카드 추가 window.api.exportEpisodeTemplate().catch((ex) => toast('오류: ' + ex.message))}>양식 다운로드 } onClick={() => importRef && importRef.click()}>엑셀 업로드 window.api.exportEpisodes().catch((ex) => toast('오류: ' + ex.message))}>전체 추출 setImportRef(el)} onChange={handleImport} />
{/* row1: 검색 · row2: 제품 · 콘텐츠 · 월 · 테마 · 정렬 (균등폭) */} {!loading && !error && allCards.length > 0 && ( )} {loading &&
{[0,1,2].map((i) => )}
} {error && } {!loading && !error && allCards.length === 0 && ( } title="카드가 없습니다" desc="카드를 추가하거나 엑셀로 업로드하세요." action={} onClick={() => setEditEp({ episode: null })}>카드 추가} /> )} {!loading && !error && allCards.length > 0 && cards.length === 0 && ( )} {!loading && !error && cards.length > 0 && (
} onClick={toggleAll}>{allSelected ? '전체 해제' : '전체 선택'} {selCount > 0 && {selCount}개 선택}
} onClick={() => setBulkConfirm(true)}>삭제{selCount > 0 ? ` (${selCount})` : ''}
)} {!loading && !error && cards.length > 0 && (
{cards.map((ec) => (
toggleOne(ec.id)} title="선택" style={{ position: 'absolute', top: 10, right: 10, width: 16, height: 16, cursor: 'pointer', zIndex: 1 }} />
{ec.title}
{ec.description &&
{ec.description}
}
{(ec.products || []).map((p, j) => {p})}
{ec.tension &&
핵심 텐션: {ec.tension}
} {(ec.voc || ec.rtb) && (
{ec.voc &&
VOC{ec.voc}
} {ec.rtb &&
RTB{ec.rtb}
}
)}
inlinePlace(ec, themeId)} />
} onClick={() => setEditEp({ episode: ec })}>편집 } onClick={() => setConfirm({ episode: ec })}>삭제
))}
)} {editEp && setEditEp(null)} onSave={(body) => (body.id ? saveEpisode(body) : createCard(body))} />} {confirm && ( setConfirm(null)} footer={<> setConfirm(null)}>취소} onClick={delEpisode}>삭제}>

이 에피소드가 삭제됩니다.

)} {bulkConfirm && ( setBulkConfirm(false)} footer={<> setBulkConfirm(false)}>취소} onClick={bulkDelete}>삭제}>

선택한 {selCount}개 카드가 라이브러리에서 완전히 삭제됩니다.{selRegistered > 0 ? ` 그중 ${selRegistered}개는 등록된 콘텐츠에서도 함께 제거됩니다.` : ''}

)}
); } /* ============================================================ EPISODE ADMIN (CNX / ADMIN only — authoring: create, edit, delete themes & episodes, Excel import, pool placement) ============================================================ */ function EpisodeAdmin({ user, toast, navigate }) { const [view, setView] = useSE('contents'); // 'contents' | 'cards' const [detailId, setDetailId] = useSE(null); // null = grid, id = detail return (
에피소드 관리
콘텐츠의 월·테마 관리와 라이브러리 카드 배치, 글로벌 에피소드 카드 생성·수정·삭제·엑셀 업로드 — CNX/ADMIN 전용.
{view === 'contents' && (detailId ? (
) : setDetailId(id)} toast={toast} /> )} {view === 'cards' && }
); } // Circled number derived from an episode's position within its theme (①②③…). // Replaces the old hand-entered `code` so numbering always tracks order. function epNum(i) { return i < 20 ? String.fromCharCode(0x2460 + i) : `${i + 1}.`; } function flatEpisodes(themes) { const m = {}; (themes || []).forEach((t) => t.episodes.forEach((e, i) => { m[e.id] = { ...e, code: epNum(i), themeName: t.name }; })); return m; } /* ---------------- Month tab bar (sub-nav inside a tab) ---------------- */ function MonthTabBar({ project, month, navigate, tab, onSelect }) { return (
{project.months.map((m) => ( ))}
); } /* personas → compact chip row (only non-empty); shared across card displays */ function PersonaChips({ ep }) { const list = epPersonaOptions(ep); if (list.length === 0) return null; return (
{list.map((p, i) => ( {p} ))}
); } /* ---------------- Episode card ---------------- */ function EpisodeCard({ ep, selected, personaLabel, isStaff, onToggle, onOpen, onEdit, onPause, onDelete }) { const paused = ep.status === 'paused'; return (
{!paused && ( )} {paused && } {paused &&
일시중지
}
{ep.title}
{(ep.products || []).length > 0 &&
{(ep.products || []).map((p, i) => {p})}
}
{personaLabel ? {personaLabel} : (selected ? 페르소나 미지정 : 선택 안 됨)} {isStaff ? e.stopPropagation()}>}>
: 상세 }
); } /* ---------------- Channel feedback (reference design, in modal) ---------------- */ const FEED_CHANNELS = [['INSTAGRAM','Instagram'],['LINKEDIN','LinkedIn'],['NEWSROOM','Newsroom'],['OTHER','기타']]; const ELEM_LABEL = { VISUAL:'비주얼', COPY:'카피', CAPTION:'캡션', THUMBNAIL:'썸네일', HASHTAG:'해시태그', VIDEO:'영상', OTHER:'기타' }; const SEV = { CRITICAL:{t:'Critical',c:'#C8102E',bg:'#fdecef'}, HIGH:{t:'High',c:'#c98a12',bg:'#fbf1e0'}, MEDIUM:{t:'Medium',c:'#b08900',bg:'#f9f4e3'}, NOTE:{t:'Note',c:'#6b7280',bg:'#f1f2f4'} }; const ROLE_BADGE = { LGE:{c:'#C8102E',bg:'#fdecef'}, CNX:{c:'#1a6ec7',bg:'#e8f1fb'}, ADMIN:{c:'#7c3aed',bg:'#f1ebfc'} }; const chLabel = (k) => (FEED_CHANNELS.find(([c]) => c === k) || [k, k])[1]; function ChannelLinkBar({ episodeId, channel, externalLink, canEdit, onSaved }) { const [editing, setEditing] = useSE(false); const [val, setVal] = useSE(externalLink || ''); const [busy, setBusy] = useSE(false); const save = () => { setBusy(true); window.api.setChannelLink(episodeId, channel, { externalLink: val.trim() || undefined }) .then(() => { setBusy(false); setEditing(false); onSaved && onSaved(); }) .catch((e) => { setBusy(false); alert('링크 저장 실패: ' + (e.message || e)); }); }; if (editing) { return ( 📎 setVal(e.target.value)} placeholder="https://… (PPTX/드라이브 등)" style={{ fontSize: 12, width: 280, padding: '3px 6px' }} autoFocus /> ); } return ( 📎 {externalLink ? 외부 초안 링크 ↗ : '외부 초안 링크 없음'} {canEdit && setEditing(true)} style={{ cursor: 'pointer', marginLeft: 8, color: '#1a6ec7' }}>{externalLink ? '수정' : '+ 링크 등록'}} ); } function ChannelFeedbackModal({ episode, user, onClose }) { const [data, setData] = useSE(null); const [tab, setTab] = useSE('INSTAGRAM'); const [filt, setFilt] = useSE('ALL'); const [sevF, setSevF] = useSE(''); const [composeOpen, setComposeOpen] = useSE(false); const reload = () => window.api.getEpisodeFeedback(episode.id).then(setData).catch(() => setData({ channels: [], openTotal: 0 })); useEE(() => { reload(); }, [episode.id]); const isCnx = user.role === 'CNX' || user.role === 'ADMIN'; const byChannel = (ch) => ((data && data.channels) || []).find((c) => c.channel === ch) || { channel: ch, openCount: 0, feedbacks: [], externalLink: null }; const cur = byChannel(tab); let fbs = cur.feedbacks; if (filt !== 'ALL') fbs = fbs.filter((f) => f.status === filt); if (sevF) fbs = fbs.filter((f) => f.severity === sevF); return (
● {isCnx ? 'CNX — 응답·댓글 및 해결·미해결로 변경 처리 가능' : 'LGE — 피드백 작성·응답 가능'} {data && ● 미해결 {data.openTotal}} {user.role}
{FEED_CHANNELS.map(([k, lbl]) => { const c = byChannel(k); return ( { setTab(k); setComposeOpen(false); }} style={{ cursor: 'pointer', padding: '6px 0', fontWeight: tab === k ? 700 : 500, color: tab === k ? '#16181d' : '#6b7280', borderBottom: tab === k ? '2px solid #16181d' : '2px solid transparent' }}> {lbl}{c.openCount > 0 && {c.openCount}} ); })}
{!data ?
불러오는 중…
: (
{[['ALL', '전체'], ['OPEN', '미해결'], ['RESOLVED', '해결']].map(([k, l]) => ( ))}
{composeOpen && { setComposeOpen(false); reload(); }} />} {fbs.length === 0 ?
이 채널에 아직 피드백이 없습니다.
: fbs.map((f) => )}
)}
); } function FeedbackCompose({ episodeId, channel, onDone }) { const [el, setEl] = useSE('CAPTION'); const [lang, setLang] = useSE('COMMON'); const [locator, setLocator] = useSE(''); const [curExpr, setCurExpr] = useSE(''); const [sev, setSev] = useSE(''); const [body, setBody] = useSE(''); const [busy, setBusy] = useSE(false); const submit = async () => { if (!body.trim() || busy) return; setBusy(true); try { await window.api.createFeedback(episodeId, { channel, elementType: el, lang, locator: locator || undefined, currentExpression: curExpr || undefined, severity: sev || undefined, body: body.trim() }); onDone(); } finally { setBusy(false); } }; return (
setLocator(e.target.value)} style={{ width: 130 }} />
setCurExpr(e.target.value)} style={{ width: '100%', marginBottom: 6 }} />