/* ============================================================
   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 (
    <div className="page-wrap" style={embed ? { paddingTop: 4 } : null}>
      {loading && <div className="proc-flow">{[0, 1, 2, 3].map((i) => <div className="proc-step" key={i}><Skel w={30} h={30} r={9} /><Skel w="70%" style={{ marginTop: 12 }} /><Skel w="100%" h={40} style={{ marginTop: 10 }} /></div>)}</div>}
      {error && <ErrorState onRetry={reload} msg={error} />}

      {!loading && !error && (
        <>
          <div className="thisweek-head"><Icon.target style={{ width: 18, height: 18, color: 'var(--accent)' }} />할 일<span className="tw-range">{fmt(winStart)} – {fmt(winEnd)}</span>{isStaff && <button className="tw-log-btn" onClick={() => setShowLog(true)}><Icon.clock />완료 로그</button>}</div>
          {weekTasks.length === 0 ? <div className="tw-empty">표시할 할 일이 없습니다.</div> : (
            <div className="thisweek-grid">
              {cols.map((c) => (
                <div key={c.key}>
                  <div className={`tw-col-hd ${c.cls}`}>{c.label}<span className="ct">{c.tasks.length}</span></div>
                  <div className="tw-list">
                    {c.tasks.length === 0 ? <div className="tw-empty">없음</div> : (() => {
                      // 정렬: 완료 항목은 맨 아래(딤드). 미완료는 ①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 (
                        <div className={`tw-item ${c.cls}${highlighted ? ' hl' : ''}${done ? ' done' : ''}`} key={t.id}>
                          <div className="tw-task">{done && <span className="tw-done-tag">완료</span>}{t.taskLevel ? <span className="tw-lvl">[{t.taskLevel}]</span> : null}{noStar(t.task)}</div>
                          <div className="tw-due">
                            <span className="tw-date">{range}</span>
                            <span className={`tw-dday${filled ? ' filled' : ''}`}>{ddLabel}</span>
                            {isStaff && <button className={`tw-done-btn${done ? ' is-done' : ''}`} disabled={busyId === t.id} onClick={() => toggleDone(t)} title={done ? '완료 취소' : '완료 표시'}><Icon.check />{done ? '취소' : '완료'}</button>}
                          </div>
                        </div>
                      );
                    })})()}
                  </div>
                </div>
              ))}
            </div>
          )}

          <div className="page-head" style={{ marginTop: 26 }}><div className="flex between center wrap-flex" style={{ gap: 12 }}>
            <div><div className="page-title" style={{ fontSize: 20 }}>운영 프로세스</div><div className="page-desc">콘텐츠 운영 3단계 워크플로우입니다.</div></div>
            {isStaff && <Btn size="sm" variant="ghost" icon={<Icon.plus />} onClick={() => setEditStep({})}>단계 추가</Btn>}
          </div></div>

          <div className="proc-flow">
            {(data.steps || []).map((s, i) => (
              <React.Fragment key={s.id}>
                <div className="proc-step">
                  <div className="ps-head">
                    <span className="ps-num">{s.num}</span>
                    <span className="ps-label">{String(s.label || '').replace(/^[①-⑳]\s*/, '')}</span>
                    {isStaff && (
                      <Menu trigger={<button className="icon-btn" style={{ width: 26, height: 26 }}><Icon.dots /></button>}>
                        <button onClick={() => setEditStep(s)}><Icon.edit />수정</button>
                        <div className="menu-div" />
                        <button className="danger" onClick={() => setConfirm(s)}><Icon.trash />삭제</button>
                      </Menu>
                    )}
                  </div>
                  <div className="ps-desc">{s.desc}</div>
                  <div className="ps-substeps">
                    {(s.subSteps || []).map((ss) => (
                      <div className="ps-sub" key={ss.id}>
                        <div className="ps-sub-head">
                          <SubRoleBadge role={ss.role} />
                          <span className="ps-sub-title">{ss.title}</span>
                          {isStaff && (
                            <span className="ps-sub-actions">
                              <button className="icon-btn" onClick={() => setEditSub({ stepId: s.id, ...ss })}><Icon.edit /></button>
                              <button className="icon-btn danger" onClick={() => setConfirmSub(ss)}><Icon.trash /></button>
                            </span>
                          )}
                        </div>
                        {ss.content && <div className="ps-sub-content">{ss.content}</div>}
                      </div>
                    ))}
                    {isStaff && (
                      <button className="ps-sub-add" onClick={() => setEditSub({ stepId: s.id, sortOrder: (s.subSteps || []).length + 1 })}>
                        <Icon.plus />세부 스텝 추가
                      </button>
                    )}
                  </div>
                </div>
                {i < data.steps.length - 1 && <div className="proc-arrow"><Icon.arrowR /></div>}
              </React.Fragment>
            ))}
          </div>
        </>
      )}

      {editStep && <StepEditModal step={editStep.id ? editStep : null} onClose={() => setEditStep(null)} onSave={saveStep} />}
      {confirm && <Modal title="단계 삭제" sub={confirm.label} onClose={() => setConfirm(null)} footer={<><Btn onClick={() => setConfirm(null)}>취소</Btn><Btn variant="primary" icon={<Icon.trash />} onClick={delStep}>삭제</Btn></>}><p style={{ color: 'var(--ink-2)' }}>이 프로세스 단계를 삭제합니다.</p></Modal>}
      {editSub && <SubStepEditModal sub={editSub} onClose={() => setEditSub(null)} onSave={saveSub} />}
      {confirmSub && <Modal title="세부 스텝 삭제" sub={confirmSub.title} onClose={() => setConfirmSub(null)} footer={<><Btn onClick={() => setConfirmSub(null)}>취소</Btn><Btn variant="primary" icon={<Icon.trash />} onClick={delSub}>삭제</Btn></>}><p style={{ color: 'var(--ink-2)' }}>이 세부 스텝을 삭제합니다.</p></Modal>}
      {showLog && <TodoLogModal onClose={() => setShowLog(false)} />}
    </div>
  );
}

// 할 일 완료/취소 로그 (Admin·CNX 전용). 감사 로그 중 todo.complete / todo.uncomplete만 모아 보여준다.
function TodoLogModal({ onClose }) {
  const { loading, error, data, reload } = useAsync(() => window.api.getTodoLogs(), []);
  const logs = data || [];
  return (
    <Modal wide title="할 일 완료 로그" sub="완료·취소 이력 (최근순)" onClose={onClose}
      footer={<Btn onClick={onClose}>닫기</Btn>}>
      {loading && <div style={{ padding: 8 }}>{[0, 1, 2, 3].map((i) => <Skel key={i} h={18} style={{ marginBottom: 10 }} />)}</div>}
      {error && <ErrorState onRetry={reload} msg={error} />}
      {!loading && !error && logs.length === 0 && <div className="tw-empty">아직 완료/취소 기록이 없습니다.</div>}
      {!loading && !error && logs.length > 0 && (
        <div className="tbl-wrap"><table className="tbl">
          <thead><tr><th>시각</th><th>담당자</th><th>동작</th><th>할 일</th></tr></thead>
          <tbody>
            {logs.map((l) => (
              <tr key={l.id}>
                <td className="muted" style={{ whiteSpace: 'nowrap', fontSize: 12.5 }}>{fmtDateTime(l.at)}</td>
                <td><span className="flex center" style={{ gap: 6 }}><RoleBadge role={l.role} />{l.actor}</span></td>
                <td><span className={`tw-log-tag ${l.action === 'todo.complete' ? 'done' : 'undone'}`}>{l.action === 'todo.complete' ? '완료' : '취소'}</span></td>
                <td style={{ fontWeight: 600, color: 'var(--ink)' }}>{l.target || '—'}</td>
              </tr>
            ))}
          </tbody>
        </table></div>
      )}
    </Modal>
  );
}

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 <span className={`subrole-badge ${r.cls}`}>{r.label}</span>;
}

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 (
    <Modal wide title={sub?.id ? '세부 스텝 수정' : '세부 스텝 추가'} onClose={onClose}
      footer={<><Btn onClick={onClose}>취소</Btn><Btn variant="primary" disabled={!f.title.trim() || busy} icon={<Icon.check />} onClick={async () => { setBusy(true); try { await onSave(f); } catch (e) { setBusy(false); } }}>{sub?.id ? '저장' : '추가'}</Btn></>}>
      <div className="frow"><label className="flabel">역할</label>
        <select className="input" value={f.role} onChange={set('role')}>
          <option value="PLAN">기획</option>
          <option value="DESIGN">디자인</option>
          <option value="CNX">CNX</option>
          <option value="LGE">LGE</option>
          <option value="PUBLISH">발행</option>
        </select>
      </div>
      <div className="frow"><label className="flabel">제목</label><input className="input" value={f.title} onChange={set('title')} placeholder="예: 01 Listening" /></div>
      <div className="frow" style={{ marginBottom: 0 }}><label className="flabel">내용</label><textarea className="textarea" value={f.content} onChange={set('content')} placeholder="세부 스텝 설명" /></div>
    </Modal>
  );
}

function StepEditModal({ step, onClose, onSave }) {
  const [f, setF] = useSO({ id: step?.id, label: step?.label || '', desc: step?.desc || '' });
  const set = (k) => (e) => setF((p) => ({ ...p, [k]: e.target.value }));
  const [subs, setSubs] = useSO((step?.subSteps || []).slice());
  const initialOrder = (step?.subSteps || []).map((s) => s.id);
  const [busy, setBusy] = useSO(false);
  const move = (i, dir) => {
    const j = i + dir;
    if (j < 0 || j >= subs.length) return;
    const next = subs.slice();
    [next[i], next[j]] = [next[j], next[i]];
    setSubs(next);
  };
  return (
    <Modal wide title={step ? '단계 수정' : '단계 추가'} onClose={onClose}
      footer={<><Btn onClick={onClose}>취소</Btn><Btn variant="primary" disabled={!f.label.trim() || busy} icon={<Icon.check />} onClick={async () => { setBusy(true); try { const order = subs.map((s) => s.id); const reordered = order.some((id, i) => id !== initialOrder[i]); await onSave({ ...f, subOrder: reordered ? order : undefined }); } catch (e) { setBusy(false); } }}>{step ? '저장' : '추가'}</Btn></>}>
      <div className="frow"><label className="flabel">단계명</label><input className="input" value={f.label} onChange={set('label')} placeholder="예: 콘텐츠 플래닝" /></div>
      <div className="frow" style={{ marginBottom: step ? undefined : 0 }}><label className="flabel">설명</label><textarea className="textarea" value={f.desc} onChange={set('desc')} /></div>
      {step && (
        <div className="frow" style={{ marginBottom: 0 }}>
          <label className="flabel">세부 스텝 순서</label>
          <div className="substep-reorder">
            {subs.length === 0 && <div className="sr-empty">세부 스텝이 없습니다. 카드에서 추가하세요.</div>}
            {subs.map((ss, i) => (
              <div className="sr-row" key={ss.id}>
                <SubRoleBadge role={ss.role} />
                <span className="sr-title">{ss.title}</span>
                <span className="sr-btns">
                  <button type="button" className="icon-btn" disabled={i === 0} onClick={() => move(i, -1)} aria-label="위로">↑</button>
                  <button type="button" className="icon-btn" disabled={i === subs.length - 1} onClick={() => move(i, 1)} aria-label="아래로">↓</button>
                </span>
              </div>
            ))}
          </div>
        </div>
      )}
    </Modal>
  );
}

/* ============================================================
   STRUCTURE  (WBS tree: L1 Task level > L2 Tasks > actions; per-level CRUD)
   ============================================================ */
function StructureView({ user, toast }) {
  const isStaff = user.role === 'ADMIN' || user.role === 'CNX';
  const { loading, error, data, reload } = useAsync(() => window.api.getWbsTree(), []);
  const [grp, setGrp] = useSO(null);
  const [act, setAct] = useSO(null);
  const [confirm, setConfirm] = useSO(null);
  if (loading) return <div style={{ padding: 16 }}><Skel h={120} /></div>;
  if (error) return <ErrorState onRetry={reload} msg={error} />;
  const tree = (data && data.tree) || [];
  const l1Options = tree.map((l1) => ({ id: l1.id, name: l1.name }));
  const l2Groups = flattenL2(tree);
  const taskOptions = tree.flatMap((l1) => (l1.children || []).map((l2) => ({ id: l2.id, name: `${l1.name} › ${l2.name}` })));
  const renderAction = (a, groupId) => (
    <div className="flex between center" key={a.id} style={{ marginLeft: 12, marginTop: 6, fontSize: 13 }}>
      <span className="flex center" style={{ gap: 6 }}><RoleBadge role={a.who} /><span>{window.api.ownerLabel(a.owner)} · {noStar(a.task)} · {String(a.startDate).slice(5, 10)}</span></span>
      {isStaff && <span className="flex" style={{ gap: 6 }}>
        <button className="icon-btn" onClick={() => setAct({ id: a.id, groupId, owner: a.owner, task: a.task, startDate: String(a.startDate).slice(0, 10), dueDate: a.dueDate ? String(a.dueDate).slice(0, 10) : '', isMilestone: a.isMilestone })}><Icon.edit /></button>
        <button className="icon-btn danger" onClick={() => setConfirm({ kind: 'action', id: a.id, label: a.task })}><Icon.trash /></button>
      </span>}
    </div>
  );
  const saveGroup = async (f) => {
    if (f.id) await window.api.updateGroup(f.id, { name: f.name });
    else await window.api.createGroup({ level: f.level, name: f.name, parentId: f.level !== 'TASK_LEVEL' ? f.parentId : undefined });
    setGrp(null); toast('저장됨'); reload();
  };
  const saveAction = async (f) => {
    if (f.id) await window.api.updateWbsAction(f.id, f); else await window.api.createWbsAction(f);
    setAct(null); toast('저장됨'); reload();
  };
  const del = async () => {
    const c = confirm; setConfirm(null);
    if (c.kind === 'group') await window.api.deleteGroup(c.id); else await window.api.deleteWbsAction(c.id);
    toast('삭제됨'); reload();
  };
  return (
    <div className="page-wrap wbs-struct" style={{ paddingTop: 4 }}>
      <div className="flex between center" style={{ marginBottom: 12 }}>
        <div className="page-desc">WBS 구조 — Task level › Tasks › 단계 › 일정</div>
        {isStaff && <Btn size="sm" variant="ghost" icon={<Icon.plus />} onClick={() => setGrp({ level: 'TASK_LEVEL' })}>Task level 추가</Btn>}
      </div>
      {tree.length === 0 && <EmptyState icon={<Icon.layers />} title="WBS가 비어 있습니다" desc={isStaff ? 'Task level을 추가하거나 WBS를 넣어주세요.' : '아직 준비되지 않았습니다.'} />}
      {tree.map((l1) => (
        <div className="card" key={l1.id} style={{ padding: 14, marginBottom: 14 }}>
          <div className="flex between center"><b style={{ fontSize: 15 }}>{l1.name}</b>{isStaff && <span className="flex" style={{ gap: 6 }}>
            <Btn size="sm" variant="ghost" icon={<Icon.plus />} onClick={() => setGrp({ level: 'TASK', parentId: l1.id })}>Tasks</Btn>
            <button className="icon-btn" onClick={() => setGrp({ id: l1.id, level: 'TASK_LEVEL', name: l1.name })}><Icon.edit /></button>
            <button className="icon-btn danger" onClick={() => setConfirm({ kind: 'group', id: l1.id, label: l1.name })}><Icon.trash /></button>
          </span>}</div>
          {(l1.children || []).map((l2) => {
            const stepCount = (l2.children || []).reduce((n, l3) => n + (l3.actions || []).length, 0);
            const total = stepCount + (l2.actions || []).length;
            return (
            <div key={l2.id} style={{ marginLeft: 14, marginTop: 10, borderLeft: '2px solid var(--line, #eee)', paddingLeft: 12 }}>
              <div className="flex between center"><span style={{ fontWeight: 600 }}>{l2.name} <span style={{ color: 'var(--ink-2)', fontWeight: 400 }}>({total})</span></span>{isStaff && <span className="flex" style={{ gap: 6 }}>
                <Btn size="sm" variant="ghost" icon={<Icon.plus />} onClick={() => setGrp({ level: 'STEP', parentId: l2.id })}>단계</Btn>
                <button className="icon-btn" onClick={() => setGrp({ id: l2.id, level: 'TASK', name: l2.name })}><Icon.edit /></button>
                <button className="icon-btn danger" onClick={() => setConfirm({ kind: 'group', id: l2.id, label: l2.name })}><Icon.trash /></button>
              </span>}</div>
              {(l2.children || []).map((l3) => (
                <div key={l3.id} style={{ marginLeft: 12, marginTop: 8, borderLeft: '2px solid var(--line, #f0f0f0)', paddingLeft: 10 }}>
                  <div className="flex between center"><span style={{ fontWeight: 500, fontSize: 13 }}>{l3.name} <span style={{ color: 'var(--ink-2)', fontWeight: 400 }}>({(l3.actions || []).length})</span></span>{isStaff && <span className="flex" style={{ gap: 6 }}>
                    <Btn size="sm" variant="ghost" icon={<Icon.plus />} onClick={() => setAct({ groupId: l3.id })}>일정</Btn>
                    <button className="icon-btn" onClick={() => setGrp({ id: l3.id, level: 'STEP', name: l3.name })}><Icon.edit /></button>
                    <button className="icon-btn danger" onClick={() => setConfirm({ kind: 'group', id: l3.id, label: l3.name })}><Icon.trash /></button>
                  </span>}</div>
                  {(l3.actions || []).map((a) => renderAction(a, l3.id))}
                </div>
              ))}
              {(l2.actions || []).map((a) => renderAction(a, l2.id))}
            </div>
            );
          })}
        </div>
      ))}
      {grp && <GroupEditModal preset={grp} l1Options={l1Options} taskOptions={taskOptions} onClose={() => setGrp(null)} onSave={saveGroup} />}
      {act && <ActionEditModal preset={act} groups={l2Groups} onClose={() => setAct(null)} onSave={saveAction} />}
      {confirm && <Modal title="삭제" sub={confirm.label} onClose={() => setConfirm(null)} footer={<><Btn onClick={() => setConfirm(null)}>취소</Btn><Btn variant="primary" icon={<Icon.trash />} onClick={del}>삭제</Btn></>}><p style={{ color: 'var(--ink-2)' }}>삭제하시겠습니까?</p></Modal>}
    </div>
  );
}

function GroupEditModal({ preset, l1Options, taskOptions, onClose, onSave }) {
  const isTask = preset.level === 'TASK';
  const isStep = preset.level === 'STEP';
  const parentOpts = isStep ? (taskOptions || []) : (l1Options || []);
  const [f, setF] = useSO({ id: preset.id, level: preset.level, name: preset.name || '', parentId: preset.parentId || (parentOpts[0] && parentOpts[0].id) || '' });
  const [busy, setBusy] = useSO(false);
  const needsParent = (isTask || isStep) && !preset.id;
  const kindLabel = isStep ? '단계' : isTask ? 'Tasks' : 'Task level';
  return (
    <Modal title={(preset.id ? '수정' : '추가') + ' · ' + kindLabel} onClose={onClose}
      footer={<><Btn onClick={onClose}>취소</Btn><Btn variant="primary" disabled={!f.name.trim() || (needsParent && !f.parentId) || busy} icon={<Icon.check />} onClick={async () => { setBusy(true); try { await onSave(f); } catch (e) { setBusy(false); } }}>{preset.id ? '저장' : '추가'}</Btn></>}>
      {needsParent && <div className="frow"><label className="flabel">{isStep ? '상위 Tasks' : '상위 Task level'}</label><select className="select" value={f.parentId} onChange={(e) => setF((p) => ({ ...p, parentId: e.target.value }))}>{parentOpts.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}</select></div>}
      <div className="frow" style={{ marginBottom: 0 }}><label className="flabel">이름</label><input className="input" value={f.name} onChange={(e) => setF((p) => ({ ...p, name: e.target.value }))} placeholder={isStep ? '예: 디자인 제작' : isTask ? '예: 6월 / 발행' : '예: 6-7월 콘텐츠 운영'} /></div>
    </Modal>
  );
}

function ActionEditModal({ preset, groups, onClose, onSave }) {
  const [f, setF] = useSO({
    id: preset.id, groupId: preset.groupId || (groups[0] && groups[0].id) || '',
    owner: preset.owner || 'PLAN', task: preset.task || '',
    startDate: preset.startDate || '', dueDate: preset.dueDate || '',
    isMilestone: !!preset.isMilestone,
  });
  const set = (k) => (e) => setF((p) => ({ ...p, [k]: e.target.value }));
  const [busy, setBusy] = useSO(false);
  const dueInvalid = f.dueDate && f.dueDate < f.startDate;
  return (
    <Modal title={preset.id ? '일정 수정' : '일정 추가'} onClose={onClose}
      footer={<><Btn onClick={onClose}>취소</Btn><Btn variant="primary" disabled={!f.task.trim() || !f.startDate || !f.groupId || dueInvalid || busy} icon={<Icon.check />} onClick={async () => { setBusy(true); try { await onSave(f); } catch (e) { setBusy(false); } }}>{preset.id ? '저장' : '추가'}</Btn></>}>
      <div className="frow"><label className="flabel">Tasks (월/발행 그룹)</label>
        <select className="select" value={f.groupId} onChange={set('groupId')}>{groups.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}</select></div>
      <div className="frow fgrid">
        <div><label className="flabel">시작일</label><input className="input" type="date" value={f.startDate} onChange={set('startDate')} /></div>
        <div><label className="flabel">종료일 <span style={{ color: 'var(--ink-2)', fontWeight: 400 }}>(선택)</span></label><input className="input" type="date" value={f.dueDate} min={f.startDate} onChange={set('dueDate')} /></div>
      </div>
      {dueInvalid && <div style={{ marginTop: -6, marginBottom: 12, fontSize: 12, color: 'var(--danger, #d32f2f)' }}>종료일은 시작일보다 빠를 수 없습니다.</div>}
      <div className="frow"><label className="flabel">담당</label><select className="select" value={f.owner} onChange={set('owner')}>{OWNERS.map(([o, l]) => <option key={o} value={o}>{l}</option>)}</select></div>
      <label className="frow" style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}><input type="checkbox" checked={f.isMilestone} onChange={(e) => setF((p) => ({ ...p, isMilestone: e.target.checked }))} />발행 마일스톤</label>
      <div className="frow" style={{ marginBottom: 0 }}><label className="flabel">업무 내용</label><input className="input" value={f.task} onChange={set('task')} placeholder="예: [6월] 1차 제작물 전달" /></div>
    </Modal>
  );
}

/* ============================================================
   WBS GANTT — faithful spreadsheet view (left table + date timeline),
   full-bleed width, owner-color bars, filter + add
   ============================================================ */
// 막대 배경(OWNER_BAR) = 원래 진한 단색과 캘린더 파스텔의 RGB 중간값(채도 절충).
// 글씨·테두리·담당라벨은 진한 잉크(OWNER_INK).
const OWNER_BAR = { PLAN: '#82aee0', DESIGN: '#b490f4', LGE: '#e38090', PUBLISH: '#88c9a4' };
const OWNER_INK = { PLAN: '#283593', DESIGN: '#4a2a8c', LGE: '#8a0a1f', PUBLISH: '#1b6e3c' };
const parseISO = (s) => new Date(String(s).slice(0, 10) + 'T00:00:00');
function eachWeekday(start, end) {
  const out = []; const d = new Date(start.getTime());
  while (d <= end) { const w = d.getDay(); if (w >= 1 && w <= 5) out.push(new Date(d.getTime())); d.setDate(d.getDate() + 1); }
  return out;
}
const CW = 24; // gantt day-column width
// frozen left columns (담당/작업/시작/종료): widths + total
const GW = { who: 54, task: 280, start: 50, due: 50 };
const GLEFT = 434;
// 2-pane gantt: 좌측 고정 패널 + 우측 가로스크롤 패널을 같은 rows로 렌더하고
// 행 높이를 타입별 고정값으로 맞춰 두 패널을 픽셀 정렬한다. 세로 스크롤은 JS 동기화.
const HDR = { month: 24, day: 20 };       // 헤더 두 줄 높이 (좌 패널 헤더 = 합). box-sizing:border-box로 고정
const ROWH = { l1: 32, l2: 30, l3: 28, act: 29 }; // 타입별 고정 행 높이(내용+패딩 최대치 기준)
const GRP_BG = { l1: '#37474f', l2: '#d7dfe4', l3: '#eaeff2' }; // 그룹행 배경(좌·우 패널 공유)
const HOL = { bg: '#e9e9e9', tx: '#9e9e9e' };     // WBS 공휴일 = 회색(캘린더 탭은 빨강 유지)

function WbsGanttView({ project, user, toast }) {
  const isStaff = user.role === 'ADMIN' || user.role === 'CNX';
  const { loading, error, data, reload } = useAsync(
    () => Promise.all([window.api.getWbsTree(), window.api.getHolidays()]).then(([t, h]) => ({ tree: t.tree || [], holidays: h || [] })), []);
  const [fOwner, setFOwner] = useSO({ PLAN: true, DESIGN: true, LGE: true, PUBLISH: true });
  const [q, setQ] = useSO('');
  const [fs, setFs] = useSO(false);
  const [act, setAct] = useSO(null);
  const [grp, setGrp] = useSO(null);
  const [confirm, setConfirm] = useSO(null);
  const fileRef = useRO(null);
  const scrollRef = useRO(null); // 우측(가로스크롤) 패널
  const leftRef = useRO(null);   // 좌측(고정) 패널 — 세로 스크롤만 동기화
  const todayRowRef = useRO(null);
  const dl = (fn, ok, fail) => async () => {
    try { await fn(project.id); toast && toast(ok); }
    catch (e) { const m = (e && e.message) || fail; toast ? toast(m) : alert(m); }
  };
  const onImportFile = async (e) => {
    const f = e.target.files && e.target.files[0];
    e.target.value = '';
    if (!f) return;
    try {
      const r = await window.api.importWbs(f);
      const g = (r.imported && r.imported.groups) || 0;
      const a = (r.imported && r.imported.actions) || 0;
      const sk = (r.skipped && r.skipped.length) || 0;
      toast && toast(`WBS 넣기 완료 — 그룹 ${g}·일정 ${a}${sk ? `, ${sk}건 건너뜀` : ''}`);
      reload();
    } catch (err) { const m = (err && err.message) || 'WBS 넣기 실패'; toast ? toast(m) : alert(m); }
  };

  const tree = (data && data.tree) || [];
  const holMap = window.WbsCal.buildHolMap(data && data.holidays);
  const l2Groups = flattenL2(tree);
  const l1Options = tree.map((l) => ({ id: l.id, name: l.name }));
  const taskOptions = tree.flatMap((l1) => (l1.children || []).map((l2) => ({ id: l2.id, name: `${l1.name} › ${l2.name}` })));

  // build display rows (group headers + filtered action rows) + date range
  const rows = []; let minD = null, maxD = null;
  tree.forEach((l1) => {
    rows.push({ type: 'l1', id: l1.id, name: l1.name });
    (l1.children || []).forEach((l2) => {
      rows.push({ type: 'l2', id: l2.id, name: l2.name });
      const pushAct = (a, gid) => {
        const s = parseISO(a.startDate); const e = a.dueDate ? parseISO(a.dueDate) : s;
        if (!minD || s < minD) minD = s; if (!maxD || e > maxD) maxD = e;
        if (fOwner[a.owner] && (!q || a.task.toLowerCase().includes(q.toLowerCase())))
          rows.push({ type: 'act', a, l2: gid, start: s, end: e });
      };
      // L3(STEP) header row + its actions; plus any legacy L2-direct actions
      (l2.children || []).forEach((l3) => {
        rows.push({ type: 'l3', id: l3.id, name: l3.name });
        (l3.actions || []).forEach((a) => pushAct(a, l3.id));
      });
      (l2.actions || []).forEach((a) => pushAct(a, l2.id));
    });
  });
  const cols = (minD && maxD) ? eachWeekday(minD, maxD) : [];
  const monthSpans = [];
  cols.forEach((d) => {
    const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
    const last = monthSpans[monthSpans.length - 1];
    if (last && last.key === key) last.span++; else monthSpans.push({ key, label: `${d.getMonth() + 1}월`, span: 1 });
  });
  const todayStart = new Date(TODAY + 'T00:00:00');
  // first action row not yet finished (end >= today) — the row we scroll into view
  const todayRowIdx = rows.findIndex((r) => r.type === 'act' && r.end >= todayStart);

  // On load, scroll the gantt so the past week is in view: horizontally the left edge
  // lands on D-7 (today minus 7 days) instead of today, so recent/just-passed work shows;
  // vertically the first still-active task sits at the top (finished rows scroll away).
  useEF(() => {
    if (loading || error || !rows.length) return;
    const el = scrollRef.current;
    if (!el) return;
    const d7ISO = isoOf(addDays(todayStart, -7));
    // first column on/after D-7 (cols are weekdays only, so D-7 itself may be a weekend)
    const idx = cols.findIndex((d) => isoOf(d) >= d7ISO);
    if (idx >= 0) el.scrollLeft = Math.max(0, idx * CW);
    const trg = todayRowRef.current;
    if (trg) {
      const cTop = el.getBoundingClientRect().top;
      const rTop = trg.getBoundingClientRect().top;
      el.scrollTop = Math.max(0, el.scrollTop + (rTop - cTop) - 48);
    }
    if (leftRef.current) leftRef.current.scrollTop = el.scrollTop; // 좌측 패널 세로 정렬
  }, [loading, error, rows.length]);

  // 우측 패널 세로 스크롤 → 좌측 고정 패널에 그대로 반영(행 정렬 유지)
  const onGanttScroll = () => { if (leftRef.current && scrollRef.current) leftRef.current.scrollTop = scrollRef.current.scrollTop; };

  const saveAction = async (f) => { f.id ? await window.api.updateWbsAction(f.id, f) : await window.api.createWbsAction(f); setAct(null); toast('저장됨'); reload(); };
  const saveGroup = async (f) => { f.id ? await window.api.updateGroup(f.id, { name: f.name }) : await window.api.createGroup({ level: f.level, name: f.name, parentId: f.level === 'TASK' ? f.parentId : undefined }); setGrp(null); toast('저장됨'); reload(); };
  const del = async () => { const c = confirm; setConfirm(null); if (c.kind === 'group') await window.api.deleteGroup(c.id); else await window.api.deleteWbsAction(c.id); toast('삭제됨'); reload(); };

  const th = (w, extra) => ({ width: w, minWidth: w, padding: '4px 6px', background: '#37474f', color: '#fff', fontWeight: 600, textAlign: 'center', position: 'sticky', top: 0, ...extra });
  const dayTh = (hol) => ({ width: CW, minWidth: CW, padding: '2px 0', textAlign: 'center', fontWeight: 400, fontSize: 10, color: hol ? '#C8102E' : '#90a4ae', background: hol ? '#fde8ea' : '#fafafa', position: 'sticky', top: 28 });

  const wrap = fs
    ? { position: 'fixed', inset: 0, zIndex: 1000, background: '#fff', padding: 16, display: 'flex', flexDirection: 'column', boxSizing: 'border-box' }
    : { width: '100%', boxSizing: 'border-box' };
  const boxMaxH = fs ? 'calc(100vh - 84px)' : 'calc(100vh - 250px)';

  return (
    <div style={wrap}>
      <div className="flex between center wrap-flex" style={{ gap: 12, margin: fs ? '0 0 10px' : '4px 0 12px' }}>
        <div className="flex center" style={{ gap: 8, flexWrap: 'wrap', paddingLeft: 8 }}>
          <span style={{ fontSize: 12, color: 'var(--ink-2)' }}>필터</span>
          {/* ● 점-칩 필터(캘린더 cal-chip 형식 차용, 인라인으로 캘린더 CSS와 독립): 흰 배경 + 역할색 점·글씨, 꺼짐=흐림 */}
          {OWNERS.map(([o, lbl]) => (
            <button key={o} onClick={() => setFOwner((p) => ({ ...p, [o]: !p[o] }))}
              style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, fontWeight: fOwner[o] ? 600 : 400, padding: '3px 10px', borderRadius: 14, cursor: 'pointer', opacity: fOwner[o] ? 1 : 0.5, color: OWNER_INK[o], background: '#fff', border: '1px solid var(--line, #e0e0e0)' }}>
              <span style={{ width: 9, height: 9, borderRadius: '50%', background: 'currentColor' }} />{lbl}</button>
          ))}
          <input className="input" style={{ height: 30, width: 200 }} placeholder="작업 내용 검색" value={q} onChange={(e) => setQ(e.target.value)} />
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8, marginRight: 6 }}>
          <div className="flex" style={{ gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
            <Btn size="sm" variant="ghost" icon={<Icon.doc />} onClick={dl((id) => window.api.exportWbs(id), 'WBS를 내보냈습니다', 'WBS 내보내기 실패')}>WBS 내보내기</Btn>
            {isStaff && <>
              <input ref={fileRef} type="file" accept=".xlsx" style={{ display: 'none' }} onChange={onImportFile} />
              <Btn size="sm" variant="ghost" icon={<Icon.inbox />} onClick={() => fileRef.current && fileRef.current.click()}>WBS 넣기</Btn>
            </>}
            {isStaff && <Btn size="sm" variant="ghost" icon={<Icon.layers />} onClick={dl((id) => window.api.exportWbsTemplate(id), '입력 양식을 받았습니다', '입력 양식 생성 실패')}>입력양식 받기</Btn>}
          </div>
          <div className="flex" style={{ gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
            {isStaff && <Btn size="sm" variant="ghost" icon={<Icon.plus />} onClick={() => setGrp({ level: 'TASK_LEVEL' })}>Task Level</Btn>}
            <Btn size="sm" variant="ghost" onClick={() => setFs((v) => !v)}>{fs ? '✕ 닫기' : '⛶ 전체화면'}</Btn>
          </div>
        </div>
      </div>

      {loading && <div style={{ padding: 16 }}><Skel h={240} /></div>}
      {error && <ErrorState onRetry={reload} msg={error} />}
      {!loading && !error && rows.length === 0 && <EmptyState icon={<Icon.layers />} title="WBS가 비어 있습니다" desc={isStaff ? '일정을 추가하거나 WBS를 넣어주세요.' : '아직 준비되지 않았습니다.'} />}

      {!loading && !error && rows.length > 0 && (() => {
        const bb = '1px solid #f0f0f0';
        // 좌측 고정 패널의 한 행(담당/작업/시작/종료) 렌더
        const leftRow = (r, ri) => {
          if (r.type === 'l1') return (
            <tr key={ri} style={{ height: ROWH.l1 }}><td colSpan={4} style={{ background: GRP_BG.l1, color: '#fff', fontWeight: 700, padding: '6px 10px' }}>
              <span className="flex between center"><span>{r.name}</span>{isStaff && <span className="flex" style={{ gap: 8 }}>
                <button onClick={() => setGrp({ level: 'TASK', parentId: r.id })} style={{ background: 'rgba(255,255,255,.15)', color: '#fff', border: 0, borderRadius: 5, padding: '2px 8px', cursor: 'pointer', fontSize: 11 }}>+ Tasks</button>
                <button onClick={() => setGrp({ id: r.id, level: 'TASK_LEVEL', name: r.name })} style={{ background: 'transparent', color: '#cfd8dc', border: 0, cursor: 'pointer', fontSize: 11 }}>수정</button>
                <button onClick={() => setConfirm({ kind: 'group', id: r.id, label: r.name })} style={{ background: 'transparent', color: '#ffab91', border: 0, cursor: 'pointer', fontSize: 11 }}>삭제</button>
              </span>}</span></td></tr>
          );
          if (r.type === 'l2') return (
            <tr key={ri} style={{ height: ROWH.l2 }}><td colSpan={4} style={{ background: GRP_BG.l2, fontWeight: 700, fontSize: 13, padding: '5px 10px 5px 20px' }}>
              <span className="flex between center"><span>{r.name}</span>{isStaff && <span className="flex" style={{ gap: 8 }}>
                <button onClick={() => setGrp({ level: 'STEP', parentId: r.id })} style={{ background: 'var(--accent)', color: '#fff', border: 0, borderRadius: 5, padding: '2px 8px', cursor: 'pointer', fontSize: 11 }}>+ 단계</button>
                <button onClick={() => setGrp({ id: r.id, level: 'TASK', name: r.name })} style={{ background: 'transparent', color: '#607d8b', border: 0, cursor: 'pointer', fontSize: 11 }}>수정</button>
                <button onClick={() => setConfirm({ kind: 'group', id: r.id, label: r.name })} style={{ background: 'transparent', color: '#c62828', border: 0, cursor: 'pointer', fontSize: 11 }}>삭제</button>
              </span>}</span></td></tr>
          );
          if (r.type === 'l3') return (
            <tr key={ri} style={{ height: ROWH.l3 }}><td colSpan={4} style={{ background: GRP_BG.l3, fontWeight: 600, fontSize: 12, color: '#455a64', padding: '3px 10px 3px 32px' }}>
              <span className="flex between center"><span>{r.name}</span>{isStaff && <span className="flex" style={{ gap: 8 }}>
                <button onClick={() => setAct({ groupId: r.id })} style={{ background: 'var(--accent)', color: '#fff', border: 0, borderRadius: 5, padding: '2px 8px', cursor: 'pointer', fontSize: 11 }}>+ 일정</button>
                <button onClick={() => setGrp({ id: r.id, level: 'STEP', name: r.name })} style={{ background: 'transparent', color: '#607d8b', border: 0, cursor: 'pointer', fontSize: 11 }}>수정</button>
                <button onClick={() => setConfirm({ kind: 'group', id: r.id, label: r.name })} style={{ background: 'transparent', color: '#c62828', border: 0, cursor: 'pointer', fontSize: 11 }}>삭제</button>
              </span>}</span></td></tr>
          );
          const a = r.a; const ink = OWNER_INK[a.owner];
          return (
            <tr key={ri} style={{ height: ROWH.act }}>
              <td style={{ width: GW.who, textAlign: 'center', color: ink, fontWeight: 600, padding: '3px 2px', borderBottom: bb }}>{window.api.ownerLabel(a.owner)}</td>
              <td style={{ width: GW.task, textAlign: 'left', padding: '3px 8px', borderBottom: bb }}>
                <span className="flex between center" style={{ gap: 6 }}>
                  <span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: GW.task - 54 }} title={noStar(a.task)}>{noStar(a.task)}</span>
                  {isStaff && <span className="flex" style={{ gap: 2 }}>
                    <button className="icon-btn" style={{ width: 22, height: 22 }} onClick={() => setAct({ id: a.id, groupId: r.l2, owner: a.owner, task: a.task, startDate: String(a.startDate).slice(0, 10), dueDate: a.dueDate ? String(a.dueDate).slice(0, 10) : '', isMilestone: a.isMilestone })}><Icon.edit /></button>
                    <button className="icon-btn danger" style={{ width: 22, height: 22 }} onClick={() => setConfirm({ kind: 'action', id: a.id, label: a.task })}><Icon.trash /></button>
                  </span>}
                </span>
              </td>
              <td style={{ width: GW.start, textAlign: 'center', color: '#607d8b', whiteSpace: 'nowrap', borderBottom: bb }}>{String(a.startDate).slice(5, 10)}</td>
              <td style={{ width: GW.due, textAlign: 'center', color: '#607d8b', whiteSpace: 'nowrap', borderBottom: bb }}>{a.dueDate ? String(a.dueDate).slice(5, 10) : ''}</td>
            </tr>
          );
        };
        // 우측 가로스크롤 패널의 한 행(날짜 칸들)
        const rightRow = (r, ri) => {
          const grpBg = GRP_BG[r.type] || null;
          if (grpBg) return <tr key={ri} style={{ height: ROWH[r.type] }}><td colSpan={cols.length || 1} style={{ background: grpBg }}></td></tr>;
          const a = r.a; const fill = OWNER_BAR[a.owner];
          return (
            <tr key={ri} ref={ri === todayRowIdx ? todayRowRef : undefined} style={{ height: ROWH.act }}>
              {cols.map((d, ci) => { const hol = holMap[isoOf(d)]; const inBar = d >= r.start && d <= r.end; const isToday = isoOf(d) === TODAY;
                return <td key={ci} style={{ width: CW, minWidth: CW, padding: 0, background: inBar ? fill : (isToday ? '#eaf3fc' : (hol ? HOL.bg : '#fff')), borderLeft: isToday ? '1px solid #aacdf0' : '1px solid #f5f5f5', borderBottom: bb }} title={inBar ? noStar(a.task) : (isToday ? '오늘' : (hol || ''))}></td>; })}
            </tr>
          );
        };
        const tbl = { borderCollapse: 'separate', borderSpacing: 0, fontSize: 12 };
        return (
          <div style={{ display: 'flex', maxHeight: boxMaxH, border: '1px solid #e6e6e6', borderRadius: 8, overflow: 'hidden', flex: fs ? 1 : 'none' }}>
            {/* 좌측 고정 패널 — 세로 스크롤은 우측에 동기화, 가로 스크롤 없음 */}
            <div ref={leftRef} style={{ flex: '0 0 ' + GLEFT + 'px', width: GLEFT, overflow: 'hidden', borderRight: '2px solid #cfd8dc' }}>
              <table style={{ ...tbl, width: GLEFT, tableLayout: 'fixed' }}>
                <colgroup><col style={{ width: GW.who }} /><col style={{ width: GW.task }} /><col style={{ width: GW.start }} /><col style={{ width: GW.due }} /></colgroup>
                <thead><tr style={{ height: HDR.month + HDR.day }}>
                  <th style={{ boxSizing: 'border-box', height: HDR.month + HDR.day, position: 'sticky', top: 0, zIndex: 6, background: '#37474f', color: '#fff', fontWeight: 600, padding: '4px 4px' }}>담당</th>
                  <th style={{ boxSizing: 'border-box', position: 'sticky', top: 0, zIndex: 6, background: '#37474f', color: '#fff', fontWeight: 600, textAlign: 'left', padding: '4px 8px' }}>작업 내용</th>
                  <th style={{ boxSizing: 'border-box', position: 'sticky', top: 0, zIndex: 6, background: '#37474f', color: '#fff', fontWeight: 600 }}>시작</th>
                  <th style={{ boxSizing: 'border-box', position: 'sticky', top: 0, zIndex: 6, background: '#37474f', color: '#fff', fontWeight: 600 }}>종료</th>
                </tr></thead>
                <tbody>{rows.map((r, ri) => leftRow(r, ri))}</tbody>
              </table>
            </div>
            {/* 우측 가로스크롤 패널 — 가로 스크롤바가 여기에만 */}
            <div ref={scrollRef} onScroll={onGanttScroll} style={{ overflow: 'auto', flex: 1, minWidth: 0 }}>
              <table style={{ ...tbl, width: 'max-content', minWidth: '100%' }}>
                <thead>
                  <tr style={{ height: HDR.month }}>
                    {monthSpans.map((m, i) => <th key={i} colSpan={m.span} style={{ boxSizing: 'border-box', height: HDR.month, position: 'sticky', top: 0, zIndex: 5, padding: '3px 0', textAlign: 'center', fontWeight: 700, background: '#eceff1' }}>{m.label}</th>)}
                  </tr>
                  <tr style={{ height: HDR.day }}>
                    {cols.map((d, i) => { const hol = holMap[isoOf(d)]; const isToday = isoOf(d) === TODAY; return <th key={i} style={{ boxSizing: 'border-box', height: HDR.day, position: 'sticky', top: HDR.month, zIndex: 5, width: CW, minWidth: CW, padding: '2px 0', textAlign: 'center', fontWeight: isToday ? 800 : 400, fontSize: 10, color: isToday ? '#1565c0' : (hol ? HOL.tx : '#90a4ae'), background: isToday ? '#dcebfb' : (hol ? HOL.bg : '#fafafa') }} title={isToday ? '오늘' : (hol || '')}>{d.getDate()}</th>; })}
                  </tr>
                </thead>
                <tbody>{rows.map((r, ri) => rightRow(r, ri))}</tbody>
              </table>
            </div>
          </div>
        );
      })()}
      {act && <ActionEditModal preset={act} groups={l2Groups} onClose={() => setAct(null)} onSave={saveAction} />}
      {grp && <GroupEditModal preset={grp} l1Options={l1Options} taskOptions={taskOptions} onClose={() => setGrp(null)} onSave={saveGroup} />}
      {confirm && <Modal title="삭제" sub={confirm.label} onClose={() => setConfirm(null)} footer={<><Btn onClick={() => setConfirm(null)}>취소</Btn><Btn variant="primary" icon={<Icon.trash />} onClick={del}>삭제</Btn></>}><p style={{ color: 'var(--ink-2)' }}>삭제하시겠습니까?</p></Modal>}
    </div>
  );
}

/* ============================================================
   SHELL — 프로세스 / WBS sub-tabs + WBS export/import
   ============================================================ */
// 운영 Overview — 사이클 격상 헤더 + 월 탭 + 프로세스 바 + LGE 페어카드 + 발행 섹션 (v2).
function OverviewView({ user, toast }) {
  const isStaff = user.role === 'ADMIN' || user.role === 'CNX';
  const { loading, error, data, reload, setData } = useAsync(() => window.api.getOverview(), []);
  const [cycleIdx, setCycleIdx] = useSO(0);
  const [monthIdx, setMonthIdx] = useSO(0);
  const [busyId, setBusyId] = useSO(null);
  const [drawerAct, setDrawerAct] = useSO(null); // L4 클릭 → 상세·메모 모달
  const [hlOpen, setHlOpen] = useSO(true);       // 상단 "지금 챙길 일" 패널 펼침
  const [hlRole, setHlRole] = useSO('ALL'); // 패널 역할 필터(전체/LGE/CNX 단일선택, CNX·Admin 전용)
  const scrollTarget = useRO(null);              // 패널에서 클릭한 L4 id — 그 위치로 스크롤·강조할 대상
  const jumpPending = useRO(false);              // 패널 점프 진행 중 — 자동 월 선택이 점프 월을 덮지 않게(소비 후 해제)
  const drawerDirty = useRO(false);              // 메모 추가/삭제가 있었으면 닫을 때 overview 새로고침
  // overview reload는 loading=true로 화면을 잠시 비우므로(모달까지 언마운트) 모달이 열린 동안엔
  // 호출하지 않고, 닫는 순간에만 변경분을 반영한다.
  const closeDrawer = () => { const dirty = drawerDirty.current; drawerDirty.current = false; setDrawerAct(null); if (dirty) reload(); };

  // L2(월) 단계 완료 판정: 액션이 하나라도 있고 전부 완료면 그 월은 끝난 것으로 본다.
  const monthDone = (m) => {
    const acts = (m.steps || []).flatMap((s) => s.actions || []);
    return acts.length > 0 && acts.every((a) => a.completed);
  };
  // 기본 선택 월 = 끝난 월은 건너뛰고 첫 미완료 월. 모든 월이 끝났으면 마지막 월.
  const firstOpenMonth = (c) => {
    const ms = (c && c.months) || [];
    const i = ms.findIndex((m) => !monthDone(m));
    return i === -1 ? Math.max(0, ms.length - 1) : i;
  };
  // 진입·사이클 변경 시 자동으로 첫 미완료 월을 띄운다. 사용자가 월 탭을 직접 누르면 그 선택을 유지.
  // (사이클을 직접 바꿀 때는 onClick이 monthPicked를 false로 되돌리므로 별도 리셋 effect는 불필요.)
  const monthPicked = useRO(false);
  useEF(() => {
    if (monthPicked.current || jumpPending.current) return; // 수동 월 선택 또는 패널 점프 중이면 자동 선택 보류
    const cs = (data && data.cycles) || [];
    const c = cs[Math.min(cycleIdx, cs.length - 1)];
    if (c) setMonthIdx(firstOpenMonth(c));
  }, [data, cycleIdx]);

  // 패널에서 L4 클릭 → 그 L4가 위치한 STEP 카드 행으로 스크롤 + 잠깐 강조(flash). 모달 대신 "어디 있는지" 앵커.
  const flashAct = (id) => {
    const el = document.getElementById('ovact-' + id);
    if (!el) return false;
    el.scrollIntoView({ behavior: 'smooth', block: 'center' });
    el.classList.remove('ov-flash'); void el.offsetWidth; el.classList.add('ov-flash'); // 재실행 위해 리플로우
    setTimeout(() => el.classList.remove('ov-flash'), 1800);
    return true;
  };
  // 사이클·월 탭이 바뀐 뒤(=DOM 갱신 후) 대상 행을 찾아 스크롤. 같은 월이면 onClick의 rAF가 즉시 처리.
  // 한 번 시도하면 성공·실패와 무관하게 대상을 비운다 — 못 찾아도 다음 렌더에 엉뚱한 행이 강조되지 않도록.
  useEF(() => {
    if (scrollTarget.current == null) return;
    flashAct(scrollTarget.current);
    scrollTarget.current = null;
    jumpPending.current = false;
  }, [data, cycleIdx, monthIdx]);

  if (loading) return <div className="page-wrap"><Skel h={120} /><Skel h={300} style={{ marginTop: 16 }} /></div>;
  if (error) return <div className="page-wrap"><ErrorState msg={error} onRetry={reload} /></div>;
  const cycles = (data && data.cycles) || [];
  if (!cycles.length) return <div className="page-wrap"><EmptyState title="운영 데이터가 없습니다" desc="WBS를 먼저 임포트하세요." /></div>;
  const cyc = cycles[Math.min(cycleIdx, cycles.length - 1)];
  if (!cyc.months.length) return <div className="page-wrap"><EmptyState title="이 사이클에 월이 없습니다" desc="WBS에 월(L2) 그룹을 추가하세요." /></div>;
  const mon = cyc.months[Math.min(monthIdx, cyc.months.length - 1)];

  const dday = (isoDue) => {
    if (!isoDue) return null;
    const today = new Date(); today.setHours(0, 0, 0, 0);
    const due = new Date(isoDue + 'T00:00:00');
    return Math.round((due - today) / 86400000);
  };
  const ddayLabel = (n) => n == null ? '' : n === 0 ? 'D-DAY' : n > 0 ? 'D-' + n : 'D+' + (-n);

  const toggle = async (act) => {
    setBusyId(act.id);
    try {
      await (act.completed ? window.api.uncompleteTodo(act.id) : window.api.completeTodo(act.id));
      // 전체 reload는 loading=true로 화면을 비워(스켈레톤) 스크롤이 맨 위로 튄다.
      // 토글된 액션의 completed만 제자리에서 뒤집는다 — 하이라이트 패널·월완료·STEP 상태가 모두 completed에서 파생되므로 일관성 유지.
      // (한 액션이 여러 월 버킷에 중복 매핑될 수 있어 id가 일치하는 모든 occurrence를 함께 갱신.)
      const next = !act.completed;
      const flip = (a) => a.id === act.id ? { ...a, completed: next } : a;
      setData((d) => d && ({
        ...d,
        cycles: (d.cycles || []).map((c) => ({
          ...c,
          months: (c.months || []).map((m) => ({
            ...m,
            steps: (m.steps || []).map((s) => ({ ...s, actions: (s.actions || []).map(flip) })),
          })),
        })),
      }));
    } catch (e) { toast(e.message || '완료 처리 실패'); } finally { setBusyId(null); }
  };
  // 운영 Overview의 완료 체크는 CNX·Admin 전용. LGE는 토글은 물론 체크박스 자체가 보이지 않는다(렌더 제외).
  const ddN = dday(mon.dDayDue);

  // 하단은 L3(STEP) 단위 밴드. 한 STEP = 좌(LGE 업무: owner=LGE) / 우(CNX 제공: 그 외 owner=PLAN·DESIGN·PUBLISH)
  // 좌우가 같은 STEP 밴드를 공유하므로 L3 레벨이 항상 같은 높이로 정렬된다. L4(액션)는 필터 없이 전부 리스트업.

  // 오늘(로컬) YYYY-MM-DD
  const t = new Date();
  const todayISO = t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0');
  // 기한 도래/지난 미완료 항목 보유 여부(역할 무관 LGE·CNX 모두) — 그룹(스텝) → 월(L2) → 사이클(L1)로 올림
  const actNeedsAttn = (a) => !a.completed && a.startDate && a.startDate <= todayISO;
  const monthHasAttn = (m) => (m.steps || []).some((s) => (s.actions || []).some(actNeedsAttn));
  const cycleHasAttn = (c) => (c.months || []).some(monthHasAttn);
  // D-day 일수(마감 없으면 시작일을 마감으로 폴백) — 뱃지 색·그룹 분류·라벨이 공유하는 단일 출처.
  const ddayOf = (a) => dday(a.dueDate || a.startDate);
  // D-day 뱃지 하이라이트(ddayOf 기준): overdue=마감 경과(빨강) / d0=오늘 마감 D-DAY(빨강) / soon=예정·미래 마감(회색).
  // 긴급(지남+오늘)만 빨강, 예정(미래)은 회색 — 오늘 할일 패널·overview 현황 뱃지가 공유.
  const ddayCls = (a) => {
    if (a.completed) return ' done';
    const n = ddayOf(a);
    if (n == null) return ' soon';
    return n < 0 ? ' overdue' : n === 0 ? ' d0' : ' soon';
  };
  const md = (iso) => iso ? (+iso.slice(5, 7)) + '/' + (+iso.slice(8, 10)) : '';
  const ymd = (iso) => iso ? iso.slice(2).replace(/-/g, '.') : ''; // 2026-05-27 → 26.05.27
  const dateText = (s, e) => (s && e && s !== e) ? ymd(s) + ' ~ ' + ymd(e) : ymd(e || s); // 시작=종료면 단일 날짜
  // STEP 진행 마커 시퀀스(스텝박스·역할 미니카드 공통 규칙): 항목 없는 STEP=null(건너뜀),
  // 모두 완료=완료, 첫 미완료=현재, 나머지=예정.
  const statusSeq = (itemsByStep) => {
    let assigned = false;
    return itemsByStep.map((items) => {
      if (items.length === 0) return null;
      if (items.every((a) => a.completed)) return 'done';
      if (!assigned) { assigned = true; return 'current'; }
      return 'upcoming';
    });
  };
  // 역할별 STEP 액션을 한 번만 분리(상태 계산·렌더가 같은 배열을 공유 → 이중 filter 방지).
  const lgeByStep = mon.steps.map((s) => s.actions.filter((a) => a.owner === 'LGE'));
  const cnxByStep = mon.steps.map((s) => s.actions.filter((a) => a.owner !== 'LGE'));
  // 상단 스텝박스 = 전체 액션(LGE+CNX) 완료 기준(발행 단계 포함). 역할 미니카드 = 역할별 완료 기준.
  const stepStatus = statusSeq(mon.steps.map((s) => s.actions));
  const lgeStatus = statusSeq(lgeByStep);
  const cnxStatus = statusSeq(cnxByStep);

  // L4는 작업 순서(시작일 오름차순, 동률이면 마감일)대로 — 프로세스 흐름 1차→2차→3차.
  // 임박순/완료-맨아래 정렬은 쓰지 않는다(순서가 뒤집히므로). 날짜는 "YYYY-MM-DD" 문자열 비교로 충분.
  const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
  const sortActs = (acts) => [...acts].sort((x, y) =>
    cmp(x.startDate, y.startDate) || cmp(x.dueDate || x.startDate, y.dueDate || y.startDate));

  // 전역 "오늘 할 일" — 모든 사이클·월·STEP에 걸친 하이라이트 L4(미완료 + 시작일 도래, overdue 포함)를
  // 한 곳에 모은다. 현재 선택한 월과 무관하게 전체를 훑어 "지금 진행 중이거나 기한이 지난" 항목만 추린다.
  // 한 액션이 여러 월 버킷에 중복 매핑될 수 있어(예: 8-9월 공통 항목) id 기준으로 한 번만 담는다.
  // 역할 가시성: LGE 계정은 자기(owner=LGE) 항목만, CNX·Admin은 전부 본다.
  // _ci/_mi(사이클·월 인덱스)도 함께 담아 클릭 시 그 탭으로 점프할 수 있게 한다.
  const highlightedGlobal = [];
  const seenHl = new Set();
  cycles.forEach((c, ci) => (c.months || []).forEach((m, mi) => (m.steps || []).forEach((s) => (s.actions || []).forEach((a) => {
    if (actNeedsAttn(a) && (isStaff || a.owner === 'LGE') && !seenHl.has(a.id)) {
      seenHl.add(a.id);
      highlightedGlobal.push({ ...a, _cycle: c.name, _month: m.month, _ci: ci, _mi: mi });
    }
  }))));
  // 급한 순: 마감(없으면 시작) 오름차순 → 가장 많이 지난/임박한 것이 위로.
  highlightedGlobal.sort((x, y) => cmp(x.dueDate || x.startDate, y.dueDate || y.startDate));
  // 역할 필터(CNX·Admin 전용, 단일선택). 발행 등 비-LGE는 CNX로 묶는다(캘린더와 동일). LGE 계정엔 필터 없음 → 전체.
  const hlShown = (isStaff && hlRole !== 'ALL')
    ? highlightedGlobal.filter((a) => (a.owner === 'LGE' ? 'LGE' : 'CNX') === hlRole)
    : highlightedGlobal;
  // 두 그룹으로 분리 — ddayOf(마감 없으면 시작일) 기준.
  //   n<0 마감 지남 + n===0 오늘 마감(D-DAY) → "긴급 업무"(n<=0)
  //   n>0 아직 마감 전(진행 중) → "예정 업무"
  const hlUrgent = hlShown.filter((a) => { const n = ddayOf(a); return n != null && n <= 0; });
  const hlUpcoming = hlShown.filter((a) => { const n = ddayOf(a); return n == null || n > 0; });

  // 업무명 끝 "메모 N" 뱃지(제목 꼬리표) — 스텝카드 행과 전역 패널 행이 공유.
  const noteTag = (n) => n > 0 ? <span className="ov-note-tag" title={n + '개의 메모'}>메모 {n}</span> : null;

  // 제목 표기: "업무명 - 조직목록"이면 " - "에서 쪼개 1줄차=업무명 / 2줄차="- 조직목록"(아랫줄·회색·말줄임).
  // 구분자 없으면 통째로 최대 2줄 말줄임.
  const renderTitle = (task, noteN) => {
    const s = noStar(task);
    const tag = noteTag(noteN);
    const i = s.indexOf(' - ');
    if (i === -1) return <span className="ov-row-title clamp2">{s}{tag}</span>;
    return (
      <span className="ov-row-title split">
        <span className="ov-rt-main">{s.slice(0, i)}{tag}</span>
        <span className="ov-rt-sub">{s.slice(i + 1)}</span>
      </span>
    );
  };

  // L4 행 하나(제목은 renderTitle). 행 클릭 → 상세·메모 드로어. 완료 토글 버튼은 전파 차단.
  const renderRow = (a) => {
    const n = ddayOf(a); // 마감 없으면 시작일을 D-day 기준으로
    const openDrawer = () => setDrawerAct(a);
    return (
      <div key={a.id} id={'ovact-' + a.id} className={'ov-row clickable' + (a.completed ? ' done' : '')}
        role="button" tabIndex={0} title="클릭하여 상세·메모 보기"
        onClick={openDrawer}
        onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && e.target === e.currentTarget) { e.preventDefault(); openDrawer(); } }}>
        <span className={'ov-dday' + ddayCls(a)}>{a.completed ? '완료' : ddayLabel(n)}</span>
        {renderTitle(a.task, a.noteCount)}
        <span className="ov-row-date">{dateText(a.startDate, a.dueDate)}</span>
        {isStaff && <button className={'ov-check-tr' + (a.completed ? ' on' : '')} disabled={busyId === a.id} onClick={(e) => { e.stopPropagation(); toggle(a); }} title="완료 토글">{a.completed ? <Icon.check /> : ''}</button>}
      </div>
    );
  };

  // STEP 미니카드(서브그리드 한 행). 항목 없으면 내용 없는 빈 칸 — 좌우 STEP 카드 높이 정렬용 자리만 유지.
  // key는 STEP 인덱스(라벨은 중복 가능 → key 충돌 방지).
  const renderStepCard = (s, items, status, i) => items.length === 0
    ? <div key={i} className="ov-stepcard empty" aria-hidden="true" />
    : (
      <div key={i} className="ov-stepcard card">
        <div className="ov-stepcard-h">{s.label}<span className={'ov-gmark ov-gmark-' + status}>{status === 'done' ? '완료' : status === 'current' ? '현재' : '예정'}</span><span className="ov-stepcard-date">{dateText(s.start, s.due)}</span></div>
        {sortActs(items).map(renderRow)}
      </div>
    );

  // 전역 패널 행 — renderRow와 달리 어느 사이클·월·STEP인지 맥락을 함께 보여준다.
  // 클릭 → 모달이 아니라 그 L4가 실제 위치한 사이클·월 탭으로 이동 후 해당 행으로 스크롤·강조(앵커).
  const renderGlobalRow = (a) => {
    const n = ddayOf(a);
    const jump = () => {
      scrollTarget.current = a.id;
      jumpPending.current = true;          // 자동 월 선택 effect가 점프한 월을 덮어쓰지 않게(소비되면 해제 → 자동선택 복구)
      setCycleIdx(a._ci);
      setMonthIdx(a._mi);
      // 같은 사이클·월이면 setState가 no-op이라 위 effect가 안 돈다 → 다음 프레임에 직접 시도(성공·실패 무관 비움).
      requestAnimationFrame(() => {
        if (scrollTarget.current !== a.id) return; // 그 사이 다른 행을 눌렀으면 양보
        flashAct(a.id);
        scrollTarget.current = null;
        jumpPending.current = false;
      });
    };
    return (
      <div key={'g' + a.id} className="ov-hl-row clickable" role="button" tabIndex={0}
        title="클릭하여 해당 위치로 이동" onClick={jump}
        onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && e.target === e.currentTarget) { e.preventDefault(); jump(); } }}>
        <span className={'ov-dday' + ddayCls(a)}>{ddayLabel(n)}</span>
        <span className="ov-hl-main">
          <span className="ov-hl-ctx">{a._month}</span>
          <span className="ov-hl-title">{noStar(a.task)}{noteTag(a.noteCount)}</span>
        </span>
        <Icon.chevR className="ov-hl-rowgo" />
      </div>
    );
  };

  return (
    <div className="page-wrap ov-wrap">
      {/* 전역 "오늘 할 일" — L1(사이클) 필터 위에, 모든 사이클·월의 하이라이트 L4를
          「긴급 업무」(마감 지남·오늘, n<=0)·「예정 업무」(미래 마감, n>0) 두 그룹으로 노출.
          역할 필터로 비어도(hlShown=0) 패널·필터는 유지(highlightedGlobal 기준)해 다시 켤 수 있게 한다. */}
      {highlightedGlobal.length > 0 && (
        <div className={'ov-hl card' + (hlOpen ? ' open' : '')}>
          <button className="ov-hl-head" onClick={() => setHlOpen(!hlOpen)} aria-expanded={hlOpen}>
            <span className="ov-hl-bdot" />
            <span className="ov-hl-htitle">오늘 할 일</span>
            <span className="ov-hl-count">{hlShown.length}</span>
            <span className="ov-hl-chev"><Icon.chevD /></span>
          </button>
          {hlOpen && isStaff && (
            <div className="ov-hl-filter">
              <span className="ov-hl-flabel">역할</span>
              {[['ALL', '전체'], ['LGE', 'LGE'], ['CNX', 'CNX']].map(([v, label]) => (
                <button key={v} className={'cal-chip ' + (v === 'LGE' ? 'cal-bar-lge' : v === 'CNX' ? 'cal-bar-cnx' : '') + (hlRole === v ? ' on' : '')}
                  onClick={() => setHlRole(v)}>
                  {v !== 'ALL' && <span className="cal-dot" />} {label}
                </button>
              ))}
            </div>
          )}
          {hlOpen && (hlShown.length > 0
            ? <div className="ov-hl-list">
                {hlUrgent.length > 0 && <div className="ov-hl-grouph">긴급 업무</div>}
                {hlUrgent.map(renderGlobalRow)}
                {hlUpcoming.length > 0 && <div className="ov-hl-grouph">예정 업무</div>}
                {hlUpcoming.map(renderGlobalRow)}
              </div>
            : <div className="ov-hl-emptyf">선택한 역할에 해당하는 항목이 없습니다.</div>)}
        </div>
      )}
      <div className="ov-cyclebar">
        {cycles.map((c, i) => (
          <button key={c.id} className={'ov-cyclebtn' + (i === cycleIdx ? ' on' : '')} onClick={() => { monthPicked.current = false; setCycleIdx(i); setMonthIdx(firstOpenMonth(c)); }}>{c.name}{cycleHasAttn(c) && <span className="ov-attn-dot" title="기한 도래/지난 미완료 항목 있음" />}</button>
        ))}
      </div>
      <div className="seg subtab-seg" style={{ marginBottom: 16 }}>
        {cyc.months.map((m, i) => <button key={m.month} className={(i === monthIdx ? 'on red' : '') + (monthDone(m) ? ' mdone' : '')} onClick={() => { monthPicked.current = true; setMonthIdx(i); }}>{m.month}{monthHasAttn(m) && <span className="ov-attn-dot" title="기한 도래/지난 미완료 항목 있음" />}</button>)}
      </div>

      <div className="ov-context card">
        <div className="ov-title-row">
          <h2 className="ov-month">{mon.month} 콘텐츠</h2>
          <div className="ov-meta">
            {mon.periodStart && <span className="ov-period">{ymd(mon.periodStart)} ~ {ymd(mon.periodEnd)}</span>}
          </div>
        </div>

        <div className="ov-steps">
          {mon.steps.map((s, i) => (
            <div key={i} className={'ov-step ov-' + stepStatus[i]}>
              <span className="ov-step-dot">{stepStatus[i] === 'done' ? <Icon.check /> : i + 1}</span>
              <div className="ov-step-label">{s.label}</div>
              <div className="ov-step-date">{dateText(s.start, s.due)}</div>
            </div>
          ))}
        </div>
        <div className="ov-legend"><i className="lg-current" />현재 <i className="lg-upcoming" />예정 <i className="lg-done" />완료</div>
      </div>

      {/* 역할 2컬럼(LGE/CNX, 가운데 구분) — 역할 헤더는 각 컬럼 맨 위에 고정. 컬럼 안 STEP마다 미니카드(L3 분기).
          subgrid로 좌우 STEP 카드 높이를 공유해 같은 단계가 같은 높이에 정렬. (header 1행 + STEP n행) */}
      <div className="ov-cols2" style={{ gridTemplateRows: mon.steps.length ? `auto repeat(${mon.steps.length}, auto)` : 'auto' }}>
        <div className="ov-colstack">
          <div className="ov-role-h"><h3>LGE 업무</h3><small>LGE가 진행해야 할 것</small></div>
          {mon.steps.map((s, i) => renderStepCard(s, lgeByStep[i], lgeStatus[i], i))}
        </div>
        <div className="ov-colstack">
          <div className="ov-role-h"><h3>CNX 제공</h3><small>CNX가 진행하여 전달해야 할 것</small></div>
          {mon.steps.map((s, i) => renderStepCard(s, cnxByStep[i], cnxStatus[i], i))}
        </div>
      </div>

      {drawerAct && <L4DetailModal action={drawerAct} isStaff={isStaff} toast={toast}
        dateText={dateText}
        onClose={closeDrawer}
        onNotesChanged={() => { drawerDirty.current = true; }} />}
    </div>
  );
}

/* L4(WbsAction) 상세 + 메모 로그 모달(제품 레퍼런스 모달과 같은 중앙 모달 스타일).
   열람 중심: 메모를 바로 보여주고, 작성칸은 "메모 추가" 버튼으로만 펼친다(CNX·Admin).
   action에는 overview가 내려준 필드(owner/who/task/startDate/dueDate/completed/noteCount)가 들어온다. */
function L4DetailModal({ action, isStaff, dateText, toast, onClose, onNotesChanged }) {
  const a = action;
  const [notes, setNotes] = useSO(null);   // null = 로딩중
  const [composing, setComposing] = useSO(false);
  const [draft, setDraft] = useSO('');
  const [busy, setBusy] = useSO(false);
  const [delId, setDelId] = useSO(null);

  // 초기 로드는 부모를 건드리지 않는다(루프 방지). 부모 새로고침은 add/remove 시에만 dirty 표시.
  const load = async () => {
    try { const n = await window.api.getActionNotes(a.id); setNotes(n); return n; }
    catch (e) { toast(e.message || '메모를 불러오지 못했습니다'); setNotes([]); return null; }
  };
  useEF(() => { load(); }, [a.id]);

  const add = async () => {
    const body = draft.trim();
    if (!body || busy) return; // busy 가드: Ctrl+Enter 연타로 중복 메모가 생기지 않게
    setBusy(true);
    try { await window.api.addActionNote(a.id, body); setDraft(''); setComposing(false); await load(); onNotesChanged && onNotesChanged(); }
    catch (e) { toast(e.message || '메모 저장 실패'); } finally { setBusy(false); }
  };
  const remove = async (noteId) => {
    setDelId(noteId);
    try { await window.api.deleteActionNote(a.id, noteId); await load(); onNotesChanged && onNotesChanged(); }
    catch (e) { toast(e.message || '메모 삭제 실패'); } finally { setDelId(null); }
  };

  const statusLabel = a.completed ? '완료' : '진행 중';
  const count = notes ? notes.length : (a.noteCount || 0);

  return (
    <Modal wide title={noStar(a.task)} onClose={onClose}
      footer={<Btn variant="primary" onClick={onClose}>닫기</Btn>}>
      <div className="l4d-meta">
        <RoleBadge role={a.who} />
        <span>{window.api.ownerLabel(a.owner)}</span>
        <span className="l4d-dot">·</span>
        <span className={'l4d-status' + (a.completed ? ' done' : '')}>{statusLabel}</span>
        <span className="l4d-dot">·</span>
        <span>{dateText(a.startDate, a.dueDate) || '날짜 미정'}</span>
      </div>

      <div className="det-section">
        <div className="dsh flex between center" style={{ alignItems: 'center' }}>
          <span>메모{count ? ` (${count})` : ''}</span>
          {isStaff && !composing && <Btn size="sm" variant="primary" icon={<Icon.plus />} onClick={() => setComposing(true)}>메모 추가</Btn>}
        </div>

        {isStaff && composing && (
          <div className="l4d-compose">
            <textarea className="textarea" rows={3} value={draft} autoFocus
              placeholder="변경 사항·전달 사항을 메모로 남기세요 (LGE도 열람)"
              onChange={(e) => setDraft(e.target.value)}
              onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') add(); }} />
            <div className="flex between center" style={{ marginTop: 8 }}>
              <span className="muted" style={{ fontSize: 12 }}>⌘/Ctrl + Enter로 저장</span>
              <span className="flex" style={{ gap: 8 }}>
                <Btn size="sm" onClick={() => { setComposing(false); setDraft(''); }}>취소</Btn>
                <Btn size="sm" variant="primary" icon={<Icon.check />} disabled={!draft.trim() || busy} onClick={add}>저장</Btn>
              </span>
            </div>
          </div>
        )}

        {notes == null ? <div className="muted" style={{ padding: '14px 0', fontSize: 13 }}>불러오는 중…</div>
          : notes.length === 0 ? <div className="l4d-empty">아직 메모가 없습니다.{isStaff ? ' "메모 추가"로 남겨보세요.' : ' CNX·Admin이 남긴 메모가 여기에 표시됩니다.'}</div>
          : (
            <div className="l4d-log">
              {notes.map((n) => (
                <div className="l4d-note" key={n.id}>
                  <div className="l4d-note-h">
                    <Avatar name={n.author.name} color={n.author.role === 'CNX' ? 'var(--cnx-blue)' : n.author.role === 'ADMIN' ? 'var(--ink-2)' : 'var(--lg-red)'} size={26} />
                    <span className="l4d-note-who">{n.author.name}</span>
                    <RoleBadge role={n.author.role} />
                    <span className="l4d-note-when">{fmtDateTime(n.createdAt)}</span>
                    {isStaff && <button className="icon-btn danger l4d-note-del" title="메모 삭제" disabled={delId === n.id} onClick={() => remove(n.id)}><Icon.trash /></button>}
                  </div>
                  <div className="l4d-note-body">{n.body}</div>
                </div>
              ))}
            </div>
          )}
      </div>
    </Modal>
  );
}

/* ───────────────────────────────────────────────────────────
   WBS 월별 캘린더 — 구글캘린더형. /overview 소비, 읽기 전용.
   L1(사이클)·L2(월)·L3(단계)·역할 4축 계단식 필터(상위가 하위 후보를 좁힘).
   L1·L2 칩은 후보 2개 이상일 때만 노출. 역할 칩=CNX/LGE(발행은 CNX에 편입). 완료상태 비표시.
   ─────────────────────────────────────────────────────────── */
const CAL_MAX_LANES = 4; // 한 칸에 보이는 막대 레인 수(초과분은 "+N개"로 접고 클릭 시 목록 모달)
const CAL_LANE = 26; // 막대 레인 세로 피치(px). .cal-bar 높이(23px, styles.css)와 정합
const MIN_DRILL_OPTS = 2; // 드릴다운 드롭다운은 고를 후보가 2개 이상일 때만 노출(1개면 선택지 없음)
const CAL_ROLE_LABEL = { CNX: 'CNX', LGE: 'LGE' };
const CAL_WD = ['월', '화', '수', '목', '금']; // 주말(토·일)은 표시하지 않음
const CAL_DAYS = CAL_WD.length;               // 칸 수(=5). 막대/오버플로 위치 계산 기준

// 막대 라벨 — 앞에 월 토큰 + 뒤에 작업명.
// 작업명에 이미 "[..월..]" 토큰([6-7월]·[6월 3주차] 등)이 박혀 있으면 그게 가장 구체적이므로
// 그대로 쓰고(이중 대괄호 방지), 없을 때만 L2 월명("[6월]")을 앞에 붙인다.
function calBarLabel(event) {
  const raw = event.task || '';
  const m = /^\s*(\[[^\]]*월[^\]]*\])\s*/.exec(raw);   // 작업명 앞 [..월..] 토큰(주차 포함)
  if (m) return m[1] + ' ' + raw.slice(m[0].length);   // 토큰이 이미 월 정보를 가짐 → 그대로
  const monLabel = (event.l2 && /월/.test(event.l2)) ? '[' + event.l2 + ']' : null;
  return monLabel ? monLabel + ' ' + raw : raw;
}

function localTodayISO() {
  const d = new Date();
  const p = (n) => String(n).padStart(2, '0');
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}

function WbsCalendarView({ user, toast }) {
  const C = window.WbsCal;
  const { loading, error, data, reload } = useAsync(
    () => Promise.all([window.api.getOverview(), window.api.getHolidays().catch(() => [])])
      .then(([ov, hol]) => Object.assign({}, ov, { _holidays: hol || [] })), []);
  const today = new Date();
  const [ym, setYm] = useSO({ y: today.getFullYear(), m: today.getMonth() + 1 });
  // L1→L2→L3 드릴다운(단일 선택, ''=전체). 역할은 별개 CNX/LGE 토글(발행은 CNX에 편입).
  const [path, setPath] = useSO({ l1: '', l2: '', l3: '' });
  const [roles, setRoles] = useSO({ CNX: true, LGE: true });
  const [pop, setPop] = useSO(null);         // 선택 이벤트(상세 팝오버)
  const [dayModal, setDayModal] = useSO(null);   // 날짜/"+N개" 클릭 시 그 날 전체 Task 목록

  const events = useMO(() => (C && data ? C.flattenEvents(data) : []), [data]);
  // 단일 선택값을 토글맵({값:true})으로 바꿔 applyCascade에 전달. 역할은 {CNX,LGE} 맵을
  // 4번째 축으로 그대로 넘김(독립 AND, 둘 다 끄면 전부 숨김).
  const casc = useMO(() => (C ? C.applyCascade(events, {
    l1: path.l1 ? { [path.l1]: true } : null,
    l2: path.l2 ? { [path.l2]: true } : null,
    l3: path.l3 ? { [path.l3]: true } : null,
    role: roles,
  }) : { optsL1: [], optsL2: [], optsL3: [], shown: [] }), [events, path, roles]);

  if (!C) return <div className="page-wrap"><EmptyState title="캘린더 라이브러리 로드 실패" desc="페이지를 새로고침하세요." /></div>;
  if (loading) return <div className="page-wrap cal-wide"><Skel h={120} /><Skel h={300} style={{ marginTop: 16 }} /></div>;
  if (error) return <div className="page-wrap cal-wide"><ErrorState msg={error} onRetry={reload} /></div>;
  if (!events.length) return <div className="page-wrap cal-wide"><EmptyState icon={<Icon.calendar />} title="표시할 일정이 없습니다" desc="WBS에 일정(L4)을 먼저 넣어주세요." /></div>;

  const { optsL1, optsL2, optsL3, shown } = casc;
  // 필터 매핑은 월 버킷마다 중복 유지하되, 캘린더 막대는 같은 일정(id) 1개만 그린다.
  const gridShown = C.dedupeById(shown);
  // 날짜 d에 걸치는(시작≤d≤종료) 일정 목록 — 막대로 보이든 접혔든 모두. ISO 문자열 사전식 비교.
  const dayEventsOn = (d) => gridShown.filter((e) => e.start <= d && d <= e.end);
  const openDay = (d) => { const items = dayEventsOn(d); if (items.length) setDayModal({ date: d, items }); };
  const weeks = C.monthMatrix(ym.y, ym.m);
  const todayISO = localTodayISO();
  const holMap = C.buildHolMap(data && data._holidays);
  const move = (delta) => setYm((p) => {
    const m0 = p.m - 1 + delta; const y = p.y + Math.floor(m0 / 12); const m = ((m0 % 12) + 12) % 12 + 1;
    return { y, m };
  });
  // 상위 변경 시 하위 선택 리셋(드릴다운).
  const setL1 = (v) => setPath({ l1: v, l2: '', l3: '' });
  const setL2 = (v) => setPath((p) => ({ l1: p.l1, l2: v, l3: '' }));
  const setL3 = (v) => setPath((p) => ({ ...p, l3: v }));
  const roleColor = (r) => (r === 'LGE' ? 'cal-bar-lge' : 'cal-bar-cnx');

  const drill = (opts, value, onChange, allLabel) => (
    <select className="select-sm cal-drill-sel" value={value} onChange={(e) => onChange(e.target.value)}>
      <option value="">{allLabel}</option>
      {opts.map((v) => <option key={v} value={v}>{v}</option>)}
    </select>
  );

  return (
    <div className="page-wrap cal-wide">
      <div className="cal-toolbar">
        <div className="cal-nav">
          <button className="icon-btn" onClick={() => move(-1)} aria-label="이전 달"><Icon.chevL /></button>
          <div className="cal-title">{ym.y}년 {ym.m}월</div>
          <button className="icon-btn" onClick={() => move(1)} aria-label="다음 달"><Icon.chevR /></button>
          <button className="cal-today-btn" onClick={() => setYm({ y: today.getFullYear(), m: today.getMonth() + 1 })}>오늘</button>
        </div>
        <div className="cal-filters">
          <div className="cal-drill">
            {optsL1.length >= MIN_DRILL_OPTS && drill(optsL1, path.l1, setL1, '전체 Task Level')}
            {optsL2.length >= MIN_DRILL_OPTS && drill(optsL2, path.l2, setL2, '전체 Task')}
            {optsL3.length >= MIN_DRILL_OPTS && drill(optsL3, path.l3, setL3, '전체 단계')}
          </div>
          <div className="cal-chips">
            {['CNX', 'LGE'].map((r) => (
              <button key={r} className={'cal-chip ' + roleColor(r) + (roles[r] ? ' on' : '')}
                onClick={() => setRoles((p) => Object.assign({}, p, { [r]: !p[r] }))}>
                <span className="cal-dot" /> {CAL_ROLE_LABEL[r] || r}
              </button>
            ))}
          </div>
        </div>
      </div>

      {shown.length === 0 && <div className="cal-empty-note">선택한 필터에 해당하는 일정이 없습니다. 필터를 확인하세요.</div>}

      <div className="cal-grid cal-head">
        {CAL_WD.map((w, i) => <div key={i} className="cal-hcell">{w}</div>)}
      </div>

      {weeks.map((week, wi) => {
        const days = week.slice(0, CAL_DAYS);   // 월~금만
        const { bars, overflowByCol } = C.layoutWeek(days, gridShown, CAL_MAX_LANES);
        const laneCount = bars.reduce((m, b) => Math.max(m, b.lane + 1), 0);
        return (
          <div className="cal-week" key={wi}>
            <div className="cal-grid cal-daterow">
              {days.map((d, ci) => {
                const p = C.parse(d); const inMonth = p.getUTCMonth() === ym.m - 1 && p.getUTCFullYear() === ym.y;
                const hol = inMonth ? holMap[d] : null;
                const nEv = dayEventsOn(d).length;   // 그 날 일정 수(있을 때만 클릭 가능)
                return (
                  <div key={ci}
                    className={'cal-dcell' + (inMonth ? '' : ' out') + (d === todayISO ? ' today' : '') + (hol ? ' hol' : '') + (nEv ? ' clickable' : '')}
                    title={hol || (nEv ? `${nEv}개 일정 보기` : undefined)}
                    onClick={nEv ? () => openDay(d) : undefined}>
                    <span className="cal-dnum">{p.getUTCDate()}</span>
                    {hol && <span className="cal-holname">{hol}</span>}
                  </div>
                );
              })}
            </div>
            <div className="cal-bars" style={{ height: (laneCount * CAL_LANE + 20) + 'px' }}>
              {bars.map((b) => (
                <button key={(b.event.uid || b.event.id) + '-' + b.startCol}
                  className={'cal-bar ' + roleColor(b.event.role) + (b.continuesLeft ? ' cl' : '') + (b.continuesRight ? ' cr' : '')}
                  style={{ left: `calc(${(b.startCol / CAL_DAYS) * 100}% + 2px)`, width: `calc(${(b.span / CAL_DAYS) * 100}% - 4px)`, top: (b.lane * CAL_LANE) + 'px' }}
                  title={b.event.task}
                  onClick={() => setPop(b.event)}>
                  {calBarLabel(b.event)}
                </button>
              ))}
              {overflowByCol.map((n, ci) => n > 0 ? (
                <button key={'o' + ci} className="cal-more" style={{ left: `calc(${(ci / CAL_DAYS) * 100}% + 2px)`, top: (CAL_MAX_LANES * CAL_LANE) + 'px' }}
                  title="그 날 일정 전체 보기"
                  onClick={() => openDay(days[ci])}>
                  +{n}개
                </button>
              ) : null)}
            </div>
          </div>
        );
      })}

      {dayModal && (
        <Modal title={dayModal.date + ' 일정'} sub={dayModal.items.length + '개'} onClose={() => setDayModal(null)}
          footer={<Btn onClick={() => setDayModal(null)}>닫기</Btn>}>
          <div className="cal-day-list">
            {dayModal.items.map((e) => (
              <button key={e.uid || e.id} className={'cal-day-row ' + roleColor(e.role)}
                onClick={() => { setDayModal(null); setPop(e); }}>
                <span className="cal-day-dot" />
                <span className="cal-day-task">{calBarLabel(e)}</span>
                <span className="cal-day-role">{CAL_ROLE_LABEL[e.role] || e.role}</span>
              </button>
            ))}
          </div>
        </Modal>
      )}

      {pop && (
        <Modal title={pop.task} sub={CAL_ROLE_LABEL[pop.role]} onClose={() => setPop(null)}
          footer={<Btn onClick={() => setPop(null)}>닫기</Btn>}>
          <div className="cal-detail">
            {pop.l1 && <div><b>사이클</b><span>{pop.l1}</span></div>}
            {pop.l2 && <div><b>월</b><span>{pop.l2}</span></div>}
            <div><b>단계</b><span>{pop.l3}</span></div>
            <div><b>역할</b><span>{CAL_ROLE_LABEL[pop.role] || pop.role}</span></div>
            <div><b>기간</b><span>{pop.start}{pop.end !== pop.start ? ' ~ ' + pop.end : ''}</span></div>
          </div>
        </Modal>
      )}
    </div>
  );
}

function ProcessCalendar(props) {
  const { route } = props;
  const [sub, setSub] = useSO((route && ['cal', 'struct'].includes(route.sub)) ? route.sub : 'overview');
  const isStaff = props.user && (props.user.role === 'ADMIN' || props.user.role === 'CNX');
  return (
    <>
      <div className="page-wrap" style={{ paddingBottom: 0 }}>
        <div className="flex between center wrap-flex" style={{ gap: 12 }}>
          <div className="seg subtab-seg" style={{ margin: 0 }}>
            <button className={sub === 'overview' ? 'on red' : ''} onClick={() => setSub('overview')}><Icon.grid style={{ width: 16, height: 16 }} />현황 Overview</button>
            <button className={sub === 'cal' ? 'on red' : ''} onClick={() => setSub('cal')}><Icon.calendar style={{ width: 16, height: 16 }} />캘린더</button>
            <button className={sub === 'struct' ? 'on red' : ''} onClick={() => setSub('struct')}><Icon.layers style={{ width: 16, height: 16 }} />WBS</button>
          </div>
        </div>
      </div>
      {sub === 'overview' && <OverviewView {...props} />}
      {sub === 'cal' && <WbsCalendarView {...props} />}
      {sub === 'struct' && <WbsGanttView {...props} />}
    </>
  );
}

Object.assign(window, { ProcessPage, StructureView, WbsGanttView, OverviewView, WbsCalendarView, ProcessCalendar });
