/* ============================================================ Process (this-week flow) + Calendar (owner 4-row, view) + WBS Structure (CRUD) Global WBS API: /process, /calendar, /wbs, /wbs/{groups,actions}, /process-steps ============================================================ */ const { useState: useSO, useMemo: useMO, useEffect: useEF, useRef: useRO } = React; // real today → process 'this week' highlight aligns with the server's /process derivation const TODAY = new Date().toISOString().slice(0, 10); const OWNERS = [['PLAN', '기획'], ['DESIGN', '디자인'], ['LGE', 'LGE'], ['PUBLISH', '발행']]; const DOW = ['월', '화', '수', '목', '금']; // 작업명 앞에 박힌 발행 마일스톤 마커 ★를 표시에서만 제거(데이터·엑셀 라운드트립은 ★ 유지). const noStar = (s) => String(s || '').replace(/^★\s*/, ''); function mondayOf(dateStr) { const d = new Date(dateStr + 'T00:00:00'); const day = d.getDay(); d.setDate(d.getDate() - (day === 0 ? 6 : day - 1)); return d; } function addDays(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; } function isoOf(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } // All Mon–Fri weeks reaching into the given "YYYY-MM" month. function weeksOfMonth(monthStr) { const [y, m] = monthStr.split('-').map(Number); const first = new Date(y, m - 1, 1); const last = new Date(y, m, 0); const out = []; let mon = mondayOf(monthStr + '-01'); let idx = 1; while (mon <= last) { const fri = addDays(mon, 4); if (fri >= first) { out.push({ index: idx, mon: new Date(mon), fri: new Date(fri) }); idx += 1; } mon = addDays(mon, 7); } return out; } const dayIndexOf = (mon, dateStr) => Math.round((new Date(String(dateStr).slice(0, 10) + 'T00:00:00') - mon) / 86400000); // 프론트 month는 int(6,7) — 프로젝트 연도와 합쳐 "YYYY-MM"으로. function ymOf(project, monthInt) { const y = (project.periodStart || '').slice(0, 4) || String(new Date().getFullYear()); return y + '-' + String(monthInt).padStart(2, '0'); } function flattenL2(tree) { // group options for the action picker: L2 (Tasks) and L3 (단계). Actions normally // attach to a 단계(STEP); L2 kept selectable for legacy/direct attachment. const out = []; (tree || []).forEach((l1) => (l1.children || []).forEach((l2) => { out.push({ id: l2.id, path: `${l1.name} › ${l2.name}` }); (l2.children || []).forEach((l3) => out.push({ id: l3.id, path: `${l1.name} › ${l2.name} › ${l3.name}` })); })); return out; } /* ============================================================ PROCESS (4-stage flow; active step = server thisWeek, date-derived) ============================================================ */ function ProcessPage({ project, user, toast, embed }) { const isStaff = user.role === 'ADMIN' || user.role === 'CNX'; const { loading, error, data, reload } = useAsync(() => window.api.getProcess(), [project.id]); const [editStep, setEditStep] = useSO(null); const [confirm, setConfirm] = useSO(null); const [editSub, setEditSub] = useSO(null); const [confirmSub, setConfirmSub] = useSO(null); // 할일 완료 — 백엔드 영속화(WbsActionCompletion) + audit 로그. /process가 완료 상태를 내려준다. // 완료 표시는 CNX·Admin 전용. LGE는 자기 LGE 업무도 완료 처리 불가(버튼 미노출, 서버도 재검증). const isDone = (t) => !!t.completed; const [busyId, setBusyId] = useSO(null); const [showLog, setShowLog] = useSO(false); // 완료 로그 모달 (Admin/CNX 전용) const toggleDone = async (t) => { setBusyId(t.id); try { await (t.completed ? window.api.uncompleteTodo(t.id) : window.api.completeTodo(t.id)); reload(); } catch (e) { toast(e.message || '완료 처리에 실패했습니다'); } finally { setBusyId(null); } }; const saveStep = async (body) => { if (body.id) await window.api.updateStep(body.id, body); else { const num = ((data.steps || []).length || 0) + 1; await window.api.createStep({ ...body, num, sortOrder: num }); } // persist sub-step reorder (only the ids whose position changed) if (body.subOrder) { await Promise.all(body.subOrder.map((sid, i) => window.api.setSubStepOrder(sid, i + 1))); } setEditStep(null); toast(body.id ? '단계가 수정되었습니다' : '단계가 추가되었습니다'); reload(); }; const delStep = async () => { const s = confirm; setConfirm(null); await window.api.deleteStep(s.id); toast('단계 삭제됨'); reload(); }; const saveSub = async (body) => { if (body.id) await window.api.updateSubStep(body.id, body); else await window.api.createSubStep(body.stepId, body); setEditSub(null); toast(body.id ? '세부 스텝이 수정되었습니다' : '세부 스텝이 추가되었습니다'); reload(); }; const delSub = async () => { const ss = confirmSub; setConfirmSub(null); await window.api.deleteSubStep(ss.id); toast('세부 스텝 삭제됨'); reload(); }; // 할일들 범위 = 오늘 기준 D-7 ~ D+7 (서버 todoWindowTasks와 일치) const today0 = new Date(TODAY + 'T00:00:00'); const winStart = addDays(today0, -7), winEnd = addDays(today0, 7); const fmt = (d) => `${d.getMonth() + 1}/${d.getDate()}`; const weekTasks = (data && data.thisWeek) || []; const cols = [ { key: 'CNX', label: 'CNX', cls: 'cnx', tasks: weekTasks.filter((t) => t.owner === 'PLAN' || t.owner === 'DESIGN') }, { key: 'LGE', label: 'LGE', cls: 'lge', tasks: weekTasks.filter((t) => t.owner === 'LGE') }, { key: 'PUB', label: '발행일', cls: 'pub', tasks: weekTasks.filter((t) => t.owner === 'PUBLISH') }, ]; const ddayOf = (dateStr) => Math.round((new Date(String(dateStr).slice(0, 10) + 'T00:00:00') - new Date(TODAY + 'T00:00:00')) / 86400000); return (
{loading &&
{[0, 1, 2, 3].map((i) =>
)}
} {error && } {!loading && !error && ( <>
할 일{fmt(winStart)} – {fmt(winEnd)}{isStaff && }
{weekTasks.length === 0 ?
표시할 할 일이 없습니다.
: (
{cols.map((c) => (
{c.label}{c.tasks.length}
{c.tasks.length === 0 ?
없음
: (() => { // 정렬: 완료 항목은 맨 아래(딤드). 미완료는 ①D+n(지남) ②D-day ③시작~마감 사이(임박순) ④시작 전 미래(임박순). // ddayOf는 Date 생성을 동반하므로 task당 한 번만 계산해 due/start/done을 렌더까지 그대로 사용. const sorted = c.tasks .map((t) => { const due = ddayOf(t.dueDate || t.date), start = ddayOf(t.date); const rank = due < 0 ? 0 : due === 0 ? 1 : start <= 0 ? 2 : 3; return { t, due, start, rank, done: isDone(t) }; }) .sort((a, b) => (a.done - b.done) || // 완료는 맨 아래 (a.rank - b.rank) || // 미완료 D+n → D-day → 진행 중 → 미래 (a.rank === 0 ? b.due - a.due : a.due - b.due)); // 지난 건 막 지난 것 먼저, 그 외엔 임박 순 return sorted.map(({ t, due: dd, start, done }) => { const md = (s) => String(s).slice(5, 10).replace('-', '/'); const dueStr = t.dueDate || t.date; // D-day는 마감(due) 기준 const highlighted = !done && start <= 0; // 오늘이 시작일 당일~이후(진행 중·D-day·지남) → 카드 강조 const filled = !done && dd <= 0; // D-day·D+n 뱃지는 내부 칠+흰 글씨 const range = t.dueDate && md(t.dueDate) !== md(t.date) ? `${md(t.date)} ~ ${md(t.dueDate)}` : md(dueStr); const ddLabel = dd === 0 ? 'D-DAY' : dd > 0 ? `D-${dd}` : `D+${-dd}`; return (
{done && 완료}{t.taskLevel ? [{t.taskLevel}] : null}{noStar(t.task)}
{range} {ddLabel} {isStaff && }
); })})()}
))}
)}
운영 프로세스
콘텐츠 운영 3단계 워크플로우입니다.
{isStaff && } onClick={() => setEditStep({})}>단계 추가}
{(data.steps || []).map((s, i) => (
{s.num} {String(s.label || '').replace(/^[①-⑳]\s*/, '')} {isStaff && ( }>
)}
{s.desc}
{(s.subSteps || []).map((ss) => (
{ss.title} {isStaff && ( )}
{ss.content &&
{ss.content}
}
))} {isStaff && ( )}
{i < data.steps.length - 1 &&
}
))}
)} {editStep && setEditStep(null)} onSave={saveStep} />} {confirm && setConfirm(null)} footer={<> setConfirm(null)}>취소} onClick={delStep}>삭제}>

이 프로세스 단계를 삭제합니다.

} {editSub && setEditSub(null)} onSave={saveSub} />} {confirmSub && setConfirmSub(null)} footer={<> setConfirmSub(null)}>취소} onClick={delSub}>삭제}>

이 세부 스텝을 삭제합니다.

} {showLog && setShowLog(false)} />}
); } // 할 일 완료/취소 로그 (Admin·CNX 전용). 감사 로그 중 todo.complete / todo.uncomplete만 모아 보여준다. function TodoLogModal({ onClose }) { const { loading, error, data, reload } = useAsync(() => window.api.getTodoLogs(), []); const logs = data || []; return ( 닫기}> {loading &&
{[0, 1, 2, 3].map((i) => )}
} {error && } {!loading && !error && logs.length === 0 &&
아직 완료/취소 기록이 없습니다.
} {!loading && !error && logs.length > 0 && (
{logs.map((l) => ( ))}
시각담당자동작할 일
{fmtDateTime(l.at)} {l.actor} {l.action === 'todo.complete' ? '완료' : '취소'} {l.target || '—'}
)}
); } const SUBROLE = { PLAN: { label: '기획', cls: 'srb-plan' }, DESIGN: { label: '디자인', cls: 'srb-design' }, CNX: { label: 'CNX', cls: 'srb-cnx' }, LGE: { label: 'LGE', cls: 'srb-lge' }, PUBLISH: { label: '발행', cls: 'srb-pub' }, }; function SubRoleBadge({ role }) { const r = SUBROLE[role] || { label: role, cls: '' }; return {r.label}; } function SubStepEditModal({ sub, onClose, onSave }) { const [f, setF] = useSO({ id: sub?.id, stepId: sub?.stepId, role: sub?.role || 'PLAN', title: sub?.title || '', content: sub?.content || '', sortOrder: sub?.sortOrder }); const set = (k) => (e) => setF((p) => ({ ...p, [k]: e.target.value })); const [busy, setBusy] = useSO(false); return ( 취소} onClick={async () => { setBusy(true); try { await onSave(f); } catch (e) { setBusy(false); } }}>{sub?.id ? '저장' : '추가'}}>