/* ============================================================
   바른셈 — 앱 셸 / 라우팅 / 상태 / Tweaks
   ============================================================ */
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "stylePreset": "fintech",
  "accent": "#3f44a0",
  "reviewStyle": "stepper",
  "calcLayout": "table",
  "density": "regular"
}/*EDITMODE-END*/;

const DENSITY_FS = { compact: 0.92, regular: 1.0, comfy: 1.08 };

/* ============================================================
   진입 모션 — Web Animations API 기반.
   · OS reduce-motion 설정에서도 재생됨(미디어 게이팅 없음).
   · fill:"none" → 기본(정지) 상태가 항상 '보이는 상태'이므로, 모션이
     차단/정지되는 환경에서도 콘텐츠가 사라지지 않음(숨김→표시만 재생).
   · ref={playEnterRef} 로 view-host·channel 래퍼에 부착 → key 변경(화면
     전환)마다 mount 되며 1회 재생. [data-stagger] 자식은 순차 진입.
   ============================================================ */
const ENTER_KF = [{ opacity: 0, transform: "translateY(10px)" }, { opacity: 1, transform: "none" }];
function reveal(el, opts) {
  if (!el || typeof el.animate !== "function") return;
  let anim;
  try { anim = el.animate(ENTER_KF, { fill: "none", ...opts }); }
  catch (e) { return; }
  // 워치독: 모션 시계가 정지/throttle 되는 환경(일부 reduce-motion·백그라운드 탭)에서도
  // 시작 프레임(opacity:0)에 갇히지 않도록, 재생시간 경과 후 강제로 기본(가시) 상태 복귀.
  const dur = (opts.duration || 360) + (opts.delay || 0) + 400;
  const guard = setTimeout(() => { try { anim.cancel(); } catch (e) {} }, dur);
  anim.onfinish = anim.oncancel = () => clearTimeout(guard);
}
function playEnter(node) {
  if (!node || typeof node.animate !== "function") return;
  reveal(node, { duration: 360, easing: "cubic-bezier(.22,.78,.34,1)" });
  node.querySelectorAll("[data-stagger] > *").forEach((el, i) => {
    reveal(el, { duration: 440, delay: Math.min(i * 55, 360), easing: "cubic-bezier(.2,.8,.3,1)" });
  });
}
/* 모듈 스코프 = 안정적 ref 식별자 (매 렌더 재호출 방지) */
function playEnterRef(node) { if (node) playEnter(node); }

/* B+ 네비 — 고객사 워크스페이스 탭 + 상태별 기본 탭/색 */
/* 월 워크스페이스 탭 — 이번 달 급여를 끝내는 데 필요한 것만.
   연말정산은 연 1회 트랙이라 매달 여기 떠 있으면 "이번 달 할 일"처럼 보인다(E4 / W3-3). */
const CLIENT_TABS = [
  { key: "review", label: "변동분 검수", icon: "fact_check" },
  { key: "calc", label: "급여 계산", icon: "calculate" },
  { key: "documents", label: "급여대장·명세서", icon: "description" },
  { key: "statutory", label: "법정 산출물", icon: "balance" },
  { key: "filings", label: "신고 센터", icon: "receipt_long" },
  { key: "contract", label: "계약 설정", icon: "tune" },
];
// 연간 작업 — 월 마감과 주기가 다른 것들. 별도 섹션으로 분리한다.
const ANNUAL_TABS = [
  { key: "yearend", label: "연말정산 · 건보 정산", icon: "account_balance_wallet" },
];
const CLIENT_VIEWS = [...CLIENT_TABS, ...ANNUAL_TABS].map((x) => x.key);
const ROOT_VIEWS = ["dashboard", "billing", "ruleset", "clientform", "worker"];
const STATUS_TONE = { draft: "var(--ink-4)", submitted: "var(--accent)", in_review: "var(--warn)", locked: "var(--ink-3)", calculated: "var(--teal)", pending_approval: "var(--warn)", confirmed: "var(--ok)", distributed: "var(--ok)" };
const defaultTabFor = (st) => (!["locked", "calculated"].includes(st) ? ((["pending_approval", "confirmed", "distributed"].includes(st)) ? "documents" : "review") : "calc");

function nowStamp() {
  const d = new Date();
  const p = (n) => String(n).padStart(2, "0");
  return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}

/* 초기 감사 이력 시드 (이미 지나온 단계) — actorId/actorRole/approvalOf 기록 (§08-A) */
function seedAudit(clients, USERS, ym) {
  const find = (id) => USERS.find((u) => u.id === id) || null;
  const a = {};
  clients.forEach((c) => {
    const entries = [], byStage = {};
    const assignee = find(c.primaryAssigneeId) || find("u3");
    const approver = find(c.approverId) || find("u2");
    const staff = find((c.staffIds || [])[0]) || find("u4");
    const push = (action, at, stage, who, approvalOf) => {
      entries.unshift({ action, at, actor: who ? who.name : c.contact, actorId: who ? who.id : null, actorRole: who ? who.roles[0] : null, approvalOf: approvalOf || null });
      if (stage) byStage[stage] = at;
    };
    const sub = c.submittedAt;
    const has = (...ks) => ks.includes(c.status);
    if (c.status === "draft") { byStage.draft = sub; push("고객사 변동분 작성 중 (웹 폼)", sub, "draft", null); }
    else { byStage.draft = "—"; push("고객사 변동분 제출 (웹 폼)", sub, "submitted", null); }
    if (has("in_review", "locked", "calculated", "pending_approval", "confirmed", "distributed")) push("검수 시작", "2026-06-03 09:14", "in_review", assignee);
    if (has("locked", "calculated", "pending_approval", "confirmed", "distributed")) push("검수 완료·입력 잠금", "2026-06-03 10:02", "locked", assignee);
    if (has("calculated", "pending_approval", "confirmed", "distributed")) push("급여 계산 실행 · 룰셋 firm-r3 (표준 std-2026.1)", "2026-06-03 10:05", "calculated", assignee);
    if (has("pending_approval")) push("승인 상신 — 팀장 승인 요청", "2026-06-03 10:08", "pending_approval", assignee, "pending_approval");
    if (has("confirmed", "distributed")) {
      if (!c.autoApprove) push("승인 상신 — 팀장 승인 요청", "2026-06-03 10:08", "pending_approval", assignee, "pending_approval");
      push(c.autoApprove ? "자동승인 — 급여 확정" : "2차 승인 — 급여 확정", "2026-06-03 14:20", "confirmed", c.autoApprove ? assignee : approver, "confirmed");
    }
    if (c.status === "distributed") push("명세서 일괄 발송", "2026-06-03 14:25", "distributed", staff);
    a[c.id + "|" + ym] = { entries, byStage };
  });
  return a;
}

/* 주소창의 해시를 초기 상태로 읽는다. 초기값을 기본값으로 두고 mount 후 반영하면,
   첫 렌더의 상태→해시 동기화가 주소를 먼저 덮어써 새로고침·링크 공유가 깨진다. */
function hashState() {
  const m = /^#\/([^?]*)(?:\?(.*))?$/.exec(window.location.hash || "");
  if (!m) return {};
  const parts = m[1].split("/").filter(Boolean);
  const qs = new URLSearchParams(m[2] || "");
  const out = { ym: qs.get("ym"), cid: qs.get("cid"), emp: qs.get("emp") };
  if (parts[0] === "client" && parts[1]) { out.cid = parts[1]; out.view = parts[2]; }
  else if (parts[0]) out.view = parts[0];
  return out;
}

function App() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
  const BASE_CLIENTS = window.BARUNSEM.clients;
  const H0 = hashState();

  const [view, setView] = useState(() => (H0.view && [...CLIENT_VIEWS, ...ROOT_VIEWS].includes(H0.view)) ? H0.view : "dashboard");
  const [clientId, setClientId] = useState(() => (H0.cid && BASE_CLIENTS.some((c) => c.id === H0.cid)) ? H0.cid : BASE_CLIENTS[0].id);
  const [empId, setEmpId] = useState(null);
  // W1-6 귀속월 분리 — 상태·변동분·감사로그는 (고객사 × 귀속월) 키로 저장한다.
  // 전에는 월을 바꿔도 같은 객체를 봐서 헤더 문구만 바뀌고 숫자가 그대로였다(B10).
  const SEED_YM = window.BARUNSEM.PERIOD.ym;
  const pkey = (cid, ym) => cid + "|" + (ym || periodYm);
  const [statuses, setStatuses] = useState(() => Object.fromEntries(BASE_CLIENTS.map((c) => [c.id + "|" + SEED_YM, c.status])));
  const [audits, setAudits] = useState(() => seedAudit(BASE_CLIENTS, window.BARUNSEM.ORG_USERS, SEED_YM));
  // W1-1 고객사 제출 커밋 · W1-5 계약 저장 — 이 두 저장소가 비면 앞단 입력이 커널에 닿지 않는다.
  const [variations, setVariations] = useState({});   // { "cid|ym": { empId: variation } }
  const [attendanceMaps, setAttendanceMaps] = useState({});   // W1-3 매핑 프로파일 (cid → attendanceMap 오버레이)
  // W3-10 (E13) — 근태 화면의 작업 중 상태. 컴포넌트에 두면 탭 전환 한 번에 통째로 날아간다.
  const [attendanceDrafts, setAttendanceDrafts] = useState({});
  /* F2 청구 — 요금제 오버라이드 + 청구서(미수금) 원장. 요금제가 전 고객사 동일 상수면 청구 화면이 장식이다. */
  const [planOverrides, setPlanOverrides] = useState({});
  const planOf = (cid) => ({ ...(baseOf(cid) || {}).plan, ...(planOverrides[cid] || {}) });
  const savePlan = (cid, patch) => { setPlanOverrides((s) => ({ ...s, [cid]: { ...(s[cid] || {}), ...patch } })); addAudit(cid, "요금제 변경 — " + Object.keys(patch).join("·")); };
  const [invoices, setInvoices] = useState(() => window.BARUNSEM.INVOICE_SEED.map((x) => ({ ...x })));
  const markPaid = (id) => setInvoices((v) => v.map((x) => x.id === id ? { ...x, paidAt: nowStamp() } : x));
  /* B12 — 건보 정산 차액을 실제 급여(기타공제)로 흘려보낸다. 화면에서만 계산하고 끝나면 정산이 아니다. */
  const applyHealthSettlement = (cid, empId, hs) => {
    const targetYm = (Number(periodYm.slice(0, 4))) + "-04";
    const key = cid + "|" + targetYm;
    const label = hs.diff >= 0 ? "건강보험 정산 추가공제" : "건강보험 정산 환급";
    setVariations((s) => {
      const cur = s[key] || {};
      const v = cur[empId] || {};
      const etc = (v.etcDeductions || []).filter((d) => d.name !== label);
      return { ...s, [key]: { ...cur, [empId]: { ...v, etcDeductions: [...etc, { name: label, amount: hs.perMonth }] } } };
    });
    addAudit(cid, `건강보험 정산 반영 — ${label} ${window.BARUNSEM.krw(Math.abs(hs.perMonth))}원 (${targetYm} 급여, ${hs.installments}회 분납)`, { ym: targetYm });
    toast(`${targetYm} 급여에 ${label} ${window.BARUNSEM.krw(Math.abs(hs.perMonth))}원을 반영했습니다`, "ok");
  };
  const [contracts, setContracts] = useState({});     // { "cid|empId": { contract, history[] } }
  const [toasts, setToasts] = useState([]);
  const [hrChanges, setHrChanges] = useState(() => JSON.parse(JSON.stringify(window.BARUNSEM.HR_SEED || {})));
  const [rejections, setRejections] = useState({});
  // §01 검수 자동화 — 끈 경고(오탐 관리). empId → [{code, reason, by, at}] (append-only)
  const [dismissals, setDismissals] = useState({});
  const [workerId, setWorkerId] = useState(() => H0.emp || BASE_CLIENTS[0].employees[0].id);
  const [clientQuery, setClientQuery] = useState("");
  const [switcherOpen, setSwitcherOpen] = useState(false);
  const [notifOpen, setNotifOpen] = useState(false);
  const [accountOpen, setAccountOpen] = useState(false);
  const [navOpen, setNavOpen] = useState(false);   // 모바일 사이드바 드로어

  // 귀속월 (effective_date 분기 시연 — 7월부터 std-2026.2 유효)
  const PERIODS = window.BARUNSEM.PERIODS;
  const [periodYm, setPeriodYm] = useState(() => (H0.ym && PERIODS.some((p) => p.ym === H0.ym)) ? H0.ym : window.BARUNSEM.PERIOD.ym);
  const period = PERIODS.find((p) => p.ym === periodYm) || PERIODS[0];

  // W1 파생 로스터 — 화면이 보는 clients는 시드가 아니라 '커밋이 반영된 그 달의 명부'다.
  const commitStore = { variations, contracts, hrChanges };
  const clients = useMemo(
    () => BASE_CLIENTS.map((c) => {
      const r = window.BARUNSEM.rosterFor(c, periodYm, commitStore);
      const am = attendanceMaps[c.id];
      return am ? { ...r, attendanceMap: { ...r.attendanceMap, ...am } } : r;
    }),
    [periodYm, variations, contracts, hrChanges, attendanceMaps]);
  const baseOf = (cid) => BASE_CLIENTS.find((c) => c.id === cid);

  // §08-A 역할/조직 (RBAC) — 4역할(대표·팀장·담당노무사·사무) + 팀·배정 + 2단 결재
  const USERS = window.BARUNSEM.ORG_USERS;
  const TEAMS = window.BARUNSEM.ORG_TEAMS;
  const CAN = window.BARUNSEM.CAN;
  const ROLE_LABEL = window.BARUNSEM.ROLE_LABEL;
  const [actorId, setActorId] = useState("u1");          // 기본 대표(강도현) — 기존 전사총괄 동작 100% 보존
  const user = USERS.find((u) => u.id === actorId) || USERS[0];
  const userOf = (id) => USERS.find((u) => u.id === id) || null;
  const teamOf = (id) => TEAMS.find((tm) => tm.id === id) || null;
  const role = user.roles[0];
  const canEditRuleset = CAN.editRuleset(user);
  const [scope, setScope] = useState("firm");            // mine | team | firm (스코프 전환)
  /* W2-5 역할 경계 — 플랫폼 표준 발행·테넌트 관리는 '바른셈 운영자'의 일이고,
     법인 대표(owner)의 일이 아니다. 같은 actorId·같은 CAN을 쓰면 우리 법인 owner에게
     남의 법인 채택 토글이 노출된다(C3). 명시적으로 모드를 바꿔야만 열린다. */
  const [platformMode, setPlatformMode] = useState(false);
  const canPublishStandard = platformMode;
  const canManageTenants = platformMode;

  // 룰셋 거버넌스 (전역) — 표준 추적 + 법인 오버레이 + 버전이력
  const G = window.BARUNSEM.RULESET_GOV;
  const RS_NOW = "2026-06-07";
  const [stdVersions, setStdVersions] = useState(() => G.STANDARD.versions.map((v) => ({ ...v })));
  const stdVer = (id) => stdVersions.find((v) => v.id === id);
  const [gov, setGov] = useState(() => ({
    adopted: ["std-2025.2", "std-2026.1"],   // 채택 이력 (append-only · effective_date 분기 입력)
    standardRef: G.FIRM.standardRef,          // 추적 중(최신 채택) 표준
    overrides: { ...G.FIRM.overrides },
    pending: G.FIRM.pendingStandard,
    versions: [...G.FIRM.versions],
  }));
  const FIRM_NAME = G.FIRM.name; // 우리 법인 행 (멀티테넌트 표에서 식별)
  // 멀티테넌시 — 같은 표준을 쓰는 노무법인 채택 현황 (정적 → app state로 승격, 발행 시 실제 전이)
  const [tenants, setTenants] = useState(() => G.TENANTS.map((t) => ({ ...t })));
  // 가장 최근 도착(incoming) 표준 — 채택 채널의 기준 버전
  const latestIncomingId = () => { const inc = [...stdVersions].reverse().find((v) => v.status === "incoming"); return inc ? inc.id : stdVersions[stdVersions.length - 1].id; };
  // admin 데모: 한 법인의 채택 토글 — ref를 도착버전 ⇄ 직전버전으로 스왑 (채택 여부는 ref===도착버전으로 판정)
  const toggleTenant = (name) => {
    if (!platformMode) { toast("플랫폼 운영자 모드에서만 다른 법인의 채택을 조작할 수 있습니다", "warn"); return; }
    const verId = latestIncomingId();
    setTenants((ts) => ts.map((t) => {
      if (t.name !== name) return t;
      if (t.ref === verId) return { ...t, ref: t.prevRef || t.ref, state: "pending" }; // 되돌리기
      return { ...t, prevRef: t.ref, ref: verId, state: "adopted" };                    // 채택
    }));
    // 우리 법인 행이면 거버넌스 상태도 동기화
    if (name === FIRM_NAME) {
      const ours = tenants.find((t) => t.name === name);
      const adopting = !ours || ours.ref !== verId;
      if (adopting) setGov((g) => ({ ...g, adopted: g.adopted.includes(verId) ? g.adopted : [...g.adopted, verId], standardRef: verId, pending: g.pending === verId ? null : g.pending }));
    }
  };
  const newFirmVer = (label, note) => ({ id: "barunsem-" + Math.random().toString(36).slice(2, 6), label, at: RS_NOW, by: user.name, note, status: "active" });
  const supersede = (x) => ({ ...x, status: "superseded" });

  // 귀속월 ym에 유효한 표준 버전 (effective_date 분기)
  const effStdId = (ym) => window.BARUNSEM.effectiveStdId(gov.adopted, ym || period.ym);
  // 표준 버전 표시 상태 — 귀속월·채택 파생 (하드코딩 status는 fallback)
  const stdStatusOf = (v) => (v ? window.BARUNSEM.stdStatusOf(v, gov.adopted, period.ym) : "superseded");
  const appliedRuleset = (ym) => {
    const yy = ym || period.ym;
    const stdId = effStdId(yy);
    const std = stdVer(stdId);
    // 채택했으나 아직 유효일 이전 = 예약(scheduled)
    const future = gov.adopted.map(stdVer).filter((v) => v && v.effective > yy + "-31").sort((a, b) => a.effective < b.effective ? -1 : 1)[0] || null;
    return { firmId: gov.versions[0].id, stdId, stdLabel: std ? std.label : stdId, effective: std ? std.effective : "", overrideCount: Object.keys(gov.overrides).length, scheduled: future };
  };

  const adoptStandard = (toId) => {
    const eff = stdVer(toId) && stdVer(toId).effective;
    setGov((g) => ({ ...g, adopted: g.adopted.includes(toId) ? g.adopted : [...g.adopted, toId], standardRef: toId, pending: g.pending === toId ? null : g.pending, versions: [newFirmVer("표준 " + toId + " 채택", "검토 후 채택 — 유효일 " + eff + " 이후 신규 급여부터 적용") , ...g.versions.map(supersede)] }));
    toast("표준 " + toId + "을 채택했습니다 — 유효일 이후 자동 적용", "ok");
  };
  // slot = { n:<number> } | { t:<string> } — 숫자가 진실의 원천
  const saveOverride = (key, slot, note) => {
    setGov((g) => ({ ...g, overrides: { ...g.overrides, [key]: { ...slot, note, by: user.name, since: RS_NOW } }, versions: [newFirmVer("항목 재정의 — " + key, note || "법인 정책 재정의"), ...g.versions.map(supersede)] }));
    toast("항목을 재정의했습니다", "ok");
  };
  const removeOverride = (key) => {
    setGov((g) => { const ov = { ...g.overrides }; delete ov[key]; return { ...g, overrides: ov, versions: [newFirmVer("재정의 해제 — " + key, "표준값으로 환원"), ...g.versions.map(supersede)] }; });
    toast("표준값으로 되돌렸습니다");
  };
  // §6 admin: 새 표준 버전 발행 (append-only) → 구독/추적 법인에 도착 알림
  const publishStandard = (ver) => {
    if (!platformMode) { toast("표준 발행은 플랫폼 운영자만 할 수 있습니다 — 법인 대표 권한과 별개입니다", "warn"); return; }
    setStdVersions((vs) => [...vs, ver]);
    setGov((g) => ({ ...g, pending: ver.id }));
    // 발행 → 구독·추적 법인 일괄 'pending'(도착·검토 대기) 전환. ref(현 채택본)은 유지 → 채택해야 갱신
    setTenants((ts) => ts.map((t) => ({ ...t, state: "pending" })));
    toast("표준 " + ver.id + " 발행 — 구독 법인에 도착 알림 전송", "ok");
  };

  // 급여건 ↔ 룰셋 버전 스냅샷 (산정 시점 박제 → 재계산 결정성)
  const [ruleSnaps, setRuleSnaps] = useState(() => {
    const s = {};
    clients.forEach((c) => {
      if (["calculated", "confirmed", "distributed"].includes(c.status))
        s[c.id] = { firmId: "firm-r3", stdId: "std-2026.1", ym: "2026-05", at: "2026-06-03 10:05", ovr: { ...G.FIRM.overrides }, actor: "강도현 노무사" };
    });
    return s;
  });
  const snapOf = (cid) => ruleSnaps[cid] || null;
  /* C4 — 스냅샷은 append-only. 덮어쓰면 "이 급여건은 어떤 룰로 계산됐나"의 과거 답이 사라지고,
     재계산 결정성의 근거(=법적 방어의 본체)가 함께 사라진다. 최신본만 계산에 쓰고 이력은 남긴다. */
  const [snapHistory, setSnapHistory] = useState({});   // cid → [스냅샷, …] (최신 우선)
  const stampRuleset = (cid, ym) => {
    const yy = ym || period.ym;
    const a = appliedRuleset(yy);
    const snap = { firmId: a.firmId, stdId: a.stdId, ym: yy, at: nowStamp(), ovr: JSON.parse(JSON.stringify(gov.overrides)), actor: user.name, actorId: user.id };
    setRuleSnaps((s) => ({ ...s, [cid]: snap }));
    setSnapHistory((h) => ({ ...h, [cid]: [snap, ...(h[cid] || [])] }));
    return a;
  };
  const snapHistoryOf = (cid) => snapHistory[cid] || (ruleSnaps[cid] ? [ruleSnaps[cid]] : []);
  // 계산용 수치 룰셋 — 스냅샷 우선(동결) → 없으면 현재 유효 룰셋 미리보기
  const rulesetFor = (cid) => {
    const snap = ruleSnaps[cid];
    if (snap) return window.BARUNSEM.rulesetFromGov(snap.stdId, snap.ovr);
    const a = appliedRuleset(period.ym);
    return window.BARUNSEM.rulesetFromGov(a.stdId, gov.overrides);
  };
  // 재계산 권장 — 미확정(calculated) 건의 스냅샷이 현재 유효 룰셋과 다를 때 (확정/발송은 동결)
  const recalcNeeded = (cid) => {
    if (statusOf(cid) !== "calculated") return false;
    const snap = ruleSnaps[cid];
    if (!snap) return false;
    const a = appliedRuleset(snap.ym || period.ym);
    const ovrDiff = JSON.stringify(snap.ovr || {}) !== JSON.stringify(gov.overrides);
    return snap.stdId !== a.stdId || ovrDiff;
  };
  const recompute = (cid) => {
    stampRuleset(cid, (ruleSnaps[cid] && ruleSnaps[cid].ym) || period.ym);
    addAudit(cid, "룰셋 변경 반영 — 재계산 (새 스냅샷 박제)");
    toast("재계산했습니다 — 최신 룰셋 스냅샷 적용", "ok");
  };
  // C7 — 확정 해제도 권한·사유를 거친다. 전엔 1클릭으로 확정이 풀렸다.
  const unlockForRecalc = (cid, reason) => transition(cid, "calculated", { label: "확정 해제 — 재계산", reason: reason || "룰셋 변경 반영", toastText: "확정을 해제했습니다 — 재계산 가능" });
  // 버전 → 급여건 역추적 (시드 과거분 + 라이브 스냅샷)
  const runsForStd = (stdId) => {
    const live = Object.keys(ruleSnaps)
      .filter((cid) => ruleSnaps[cid].stdId === stdId)
      .map((cid) => { const sn = ruleSnaps[cid]; const c = clients.find((x) => x.id === cid); return { stdId, clientId: cid, clientName: c ? c.name : cid, ym: sn.ym || period.ym, heads: c ? c.headcount : 0, at: sn.at, live: true }; });
    const seeded = (window.BARUNSEM.RUN_SEED || []).filter((r) => r.stdId === stdId)
      .map((r) => { const c = clients.find((x) => x.id === r.clientId); return { ...r, clientName: c ? c.name : r.clientId }; });
    return [...live, ...seeded].sort((a, b) => (a.ym < b.ym ? 1 : -1));
  };

  // admin 관점 집계 — 우리 법인 실데이터 + 타 법인 추정(결정적)
  const adminRunsForStd = (stdId) => {
    const EST_MONTHS = 3, EST_HEADS_PER_CLIENT = 12;
    const ours = runsForStd(stdId);                                   // 실데이터(라이브+시드)
    const others = tenants.filter((t) => t.name !== G.FIRM.name && t.ref === stdId);
    const otherClients = others.reduce((s, t) => s + t.clients, 0);
    return {
      ours,
      oursRuns: ours.length,
      oursHeads: ours.reduce((s, r) => s + (r.heads || 0), 0),
      otherFirms: others.length,
      otherRuns: otherClients * EST_MONTHS,
      otherHeads: otherClients * EST_HEADS_PER_CLIENT,
    };
  };

  // 라이브 표준버전 기준 diff / 우리 법인 적용항목 해소 (발행된 새 버전까지 반영)
  const diffStdLive = (fromId, toId) => {
    const a = stdVer(fromId) && stdVer(fromId).items, b = stdVer(toId) && stdVer(toId).items;
    if (!a || !b) return [];
    const SR = window.BARUNSEM.slotRaw, SD = window.BARUNSEM.slotDisplay;
    return G.RS_ITEMS.filter((it) => SR(a[it.key]) !== SR(b[it.key]))
      .map((it) => ({ ...it, from: SD(it, a[it.key]), to: SD(it, b[it.key]), note: b[it.key] && b[it.key].note }));
  };
  const resolveFirmLive = () => {
    const std = stdVer(gov.standardRef) || stdVer(effStdId(period.ym));
    const SD = window.BARUNSEM.slotDisplay;
    return G.RS_ITEMS.map((it) => {
      const o = gov.overrides[it.key];
      const base = (std && std.items[it.key]) || null;
      return o
        ? { ...it, value: SD(it, o), note: o.note, source: "firm", stdValue: SD(it, base), slot: o, stdSlot: base, by: o.by, since: o.since }
        : { ...it, value: SD(it, base), note: base && base.note, source: "standard", stdValue: SD(it, base), slot: base, stdSlot: base };
    });
  };
  // 확정 동결 건수 (룰셋 화면 배지)
  const frozenConfirmed = () => clients.filter((c) => ["confirmed", "distributed"].includes(statusOf(c.id)) && ruleSnaps[c.id]);
  const recalcClients = () => clients.filter((c) => recalcNeeded(c.id));

  // 스타일 프리셋 + 액센트 + 밀도 적용
  useEffect(() => {
    document.documentElement.setAttribute("data-preset", t.stylePreset || "fintech");
    document.documentElement.style.setProperty("--accent", t.accent);
    document.documentElement.style.setProperty("--fs", DENSITY_FS[t.density] || 1);
  }, [t.stylePreset, t.accent, t.density]);

  const toast = (msg, kind) => {
    const id = Math.random();
    setToasts((ts) => [...ts, { id, msg, kind }]);
    setTimeout(() => setToasts((ts) => ts.filter((x) => x.id !== id)), 2600);
  };

  // 시드 귀속월 이전은 마감(발송 완료), 이후는 미착수(작성 중) — 월을 바꾸면 실제로 다른 상태를 본다.
  const defaultStatus = (cid, ym) => { const c = baseOf(cid); if (!c) return "draft"; return ym < SEED_YM ? "distributed" : ym > SEED_YM ? "draft" : c.status; };
  const statusOf = (cid, ym) => { const k = pkey(cid, ym); return statuses[k] != null ? statuses[k] : defaultStatus(cid, ym || periodYm); };
  const setStatusRaw = (cid, st, ym) => {
    const k = pkey(cid, ym);
    setStatuses((s) => ({ ...s, [k]: st }));
    setAudits((a) => ({ ...a, [k]: { ...(a[k] || { entries: [], byStage: {} }), byStage: { ...((a[k] || {}).byStage || {}), [st]: nowStamp() } } }));
  };
  const addAudit = (cid, action, opt) => {
    const o = typeof opt === "string" ? { actorName: opt } : (opt || {});
    const k = pkey(cid, o.ym);
    setAudits((a) => ({ ...a, [k]: { ...(a[k] || { entries: [], byStage: {} }), entries: [{ action, at: nowStamp(), actor: o.actorName || user.name, actorId: o.actorId || user.id, actorRole: user.roles[0], approvalOf: o.approvalOf || null }, ...((a[k] || {}).entries || [])] } }));
  };
  const auditOf = (cid, ym) => audits[pkey(cid, ym)] || { entries: [], byStage: {} };

  /* ── W2-1 상태 전이 게이트웨이 ────────────────────────
     상태를 바꾸는 유일한 통로. ctx에서 setStatus를 뺐으므로 칸반·단축 버튼도 반드시 여기를 지난다.
     권한(C1·C2) · 미해결 오류(C6) · 사유(C4·C5) · 룰셋 스냅샷(C4)이 한 곳에서 강제된다. */
  const openErrorsOf = (c) => {
    const a = clientAudit(c);
    const dis = (empId, code) => (dismissals[empId] || []).some((d) => d.code === code && (!d.until || d.until >= nowStamp()));
    return a.findings.filter((f) => f.severity === "error" && f.code !== "MISSING_INPUT" && !(f.empId && dis(f.empId, f.code)));
  };
  const gateFor = (cid, to, opt) => {
    const c = clients.find((x) => x.id === cid); if (!c) return { ok: false, reason: "고객사를 찾을 수 없습니다" };
    const from = statusOf(cid);
    return window.BARUNSEM.canTransition(user, c, from, to, { CAN, reason: (opt || {}).reason, openErrors: to === "locked" ? openErrorsOf(c).length : 0 });
  };
  const transition = (cid, to, opt) => {
    const o = opt || {};
    const c = clients.find((x) => x.id === cid);
    const from = statusOf(cid);
    const gate = gateFor(cid, to, o);
    if (!gate.ok) { toast(gate.reason, "warn"); return { ...gate, from }; }
    // C4 — 확정으로 가는 길은 반드시 룰셋 스냅샷을 남긴다. 스냅샷 없는 확정은 재계산 결정성을 잃는다.
    let snapNote = "";
    if (to === "calculated") { const a = stampRuleset(cid); snapNote = ` · 룰셋 ${a.firmId} (표준 ${a.stdId})`; }
    setStatusRaw(cid, to, o.ym);
    const label = o.label || gate.spec.label;
    addAudit(cid, `${label} (${window.BARUNSEM.FLOW.find((f) => f.key === from).label} → ${window.BARUNSEM.FLOW.find((f) => f.key === to).label})${snapNote}${o.reason ? " · 사유: " + o.reason : ""}`,
      { ym: o.ym, approvalOf: ["pending_approval", "confirmed", "calculated"].includes(to) && gate.spec.need === "approve" ? to : null });
    if (o.toastText !== false) toast(o.toastText || `${label} 완료`, "ok");
    return { ok: true, from, to };
  };
  const nextTransitions = (cid) => {
    const c = clients.find((x) => x.id === cid); if (!c) return [];
    return window.BARUNSEM.allowedTransitions(user, c, statusOf(cid), { CAN, openErrors: openErrorsOf(c).length });
  };

  /* W1-1 제출 = 커밋 — 고객사가 적은 값이 실제로 그 달 변동분이 되고 상태가 전이된다.
     전에는 제출이 setSubmitted(true)뿐이라 무엇을 적든 급여 결과가 같았다(B1). */
  const variationOf = (cid, ym) => variations[pkey(cid, ym)] || {};
  const submitVariation = (cid, rows, opt) => {
    const o = opt || {};
    const ym = o.ym || periodYm;
    const num = (v) => { const n = Number(String(v).replace(/,/g, "")); return isFinite(n) && n > 0 ? n : 0; };
    const map = {};
    (rows || []).forEach((r) => {
      const v = { overtime: num(r.overtime), night: num(r.night), holiday: num(r.holiday), bonus: num(r.bonus) };
      if (r.absentDays != null) v.absentDays = num(r.absentDays);
      if (r.memo) v.memo = r.memo;
      if (v.overtime || v.night || v.holiday || v.bonus || v.absentDays || v.memo) map[r.id] = v;
    });
    setVariations((s) => ({ ...s, [cid + "|" + ym]: map }));
    setStatusRaw(cid, "submitted", ym);
    addAudit(cid, `고객사 변동분 제출 — ${Object.keys(map).length}명 반영`, { actorName: o.actorName || "고객사", ym });
    return Object.keys(map).length;
  };

  // W1-3 매핑 프로파일 저장 — 파서 입력이 되는 실제 설정. 전엔 불리언 토글뿐이었다(B2).
  const saveAttendanceMap = (cid, m) => { setAttendanceMaps((s) => ({ ...s, [cid]: { ...(s[cid] || {}), ...m } })); addAudit(cid, "근태 매핑 프로파일 저장"); };

  /* W1-5 계약 저장 — 전역 반영 + append-only 변경 이력 + 재계산 필요 표시. 전엔 토스트뿐이었다(B7). */
  const contractOf = (cid, empId) => contracts[cid + "|" + empId] || null;
  const saveContract = (cid, empId, contract, opt) => {
    const o = opt || {};
    const k = cid + "|" + empId;
    setContracts((s) => {
      const cur = s[k] || { contract: {}, history: [] };
      return { ...s, [k]: { contract: { ...cur.contract, ...contract },
        history: [{ at: nowStamp(), by: o.actorName || user.name, actorId: user.id, changed: Object.keys(contract), reason: o.reason || "" }, ...cur.history] } };
    });
    addAudit(cid, `계약 변경 — ${o.empName || empId} (${Object.keys(contract).join("·")})`);
  };

  // §08-A 2단 결재 전이 — 담당(상신) ↔ 팀장/대표(승인·반려). 계산 커널과 무관(메타 레이어).
  // C2 — 액션 함수도 전부 게이트웨이 경유. 전엔 UI disabled만 걸려 있고 함수 자체엔 권한 검사가 없었다.
  const submitForApproval = (cid) => transition(cid, "pending_approval", { label: "승인 상신 — 팀장 승인 요청", toastText: "승인을 상신했습니다 — 팀장 검수큐에 노출" });
  const approveClient = (cid) => transition(cid, "confirmed", { label: "2차 승인 — 급여 확정", toastText: "승인했습니다 — 급여 확정" });
  const rejectApproval = (cid, reason) => transition(cid, "calculated", { label: "승인 반려", reason: reason || "재검토 요청", toastText: "반려했습니다 — 담당 검수큐로 회송" });

  // 인사변동 (입·퇴사 → 4대보험 자격취득·상실)
  const hrChangesOf = (cid) => hrChanges[cid] || [];
  const addHrChange = (cid, change) =>
    setHrChanges((h) => ({ ...h, [cid]: [...(h[cid] || []), { id: "hr-" + Math.random().toString(36).slice(2, 7), ...change }] }));
  const removeHrChange = (cid, id) =>
    setHrChanges((h) => ({ ...h, [cid]: (h[cid] || []).filter((x) => x.id !== id) }));

  // 반려 루프 (검수 → 고객사 입력화면 연동)
  /* C5 반려 — 상태는 draft로 되돌린다("작성 중으로 되돌아갑니다"가 참이 되도록).
     사유는 문자열 1줄이 아니라 {code, empIds[], memo} 구조. 이력은 덮어쓰지 않고 쌓는다. */
  const rejectionOf = (cid, ym) => { const list = rejections[pkey(cid, ym)] || []; return list.find((r) => !r.resolved) || list[0] || null; };
  const rejectionsOf = (cid, ym) => rejections[pkey(cid, ym)] || [];
  const rejectClient = (cid, payload) => {
    const items = Array.isArray(payload) ? payload : [{ code: "OTHER", empIds: [], memo: String(payload || "변동분 재확인 요청") }];
    const memoLine = items.map((x) => `[${x.code}]${x.empIds && x.empIds.length ? " " + x.empIds.length + "명" : ""}${x.memo ? " — " + x.memo : ""}`).join(" / ");
    const g = transition(cid, "draft", { label: "고객사 반려", reason: memoLine, toastText: "고객사로 반려했습니다 — 작성 중 상태로 복귀" });
    if (!g.ok) return g;
    const k = pkey(cid);
    setRejections((r) => ({ ...r, [k]: [{ id: "rj-" + Math.random().toString(36).slice(2, 6), items, reason: memoLine, at: nowStamp(), by: user.name, resolved: false }, ...(r[k] || [])] }));
    return g;
  };
  const resubmitClient = (cid, ym) => {
    const k = pkey(cid, ym);
    setRejections((r) => ({ ...r, [k]: (r[k] || []).map((x, i) => i === 0 ? { ...x, resolved: true, resubmittedAt: nowStamp() } : x) }));
    addAudit(cid, "고객사 재제출 — 반려분 수정 반영", { ym });
  };

  const clientTotals = (c) => {
    const rs = rulesetFor(c.id);
    const r = c.employees.reduce((acc, e) => {
      const run = window.BARUNSEM.runFor(e, c, period.ym, rs);
      return { gross: acc.gross + run.gross, ded: acc.ded + run.deductTotal, net: acc.net + run.net };
    }, { gross: 0, ded: 0, net: 0 });
    return r;
  };

  // §01 검수 findings — 고객사 단위 (끈 경고 병합 → 순수함수 소비). 제출 여부 = draft 아님.
  /* W2-6 끄기 정책 — 코드 단위·전역·무기한이던 것을 (근로자 × 코드)로 좁히고 만료를 둔다.
     "다음 달부터 다시 뜨지 않습니다"에 근거가 생긴다(C9). */
  const DISMISS_MONTHS = 3;
  const addMonths = (ym, n) => { const p = ym.split("-").map(Number); const d = new Date(p[0], p[1] - 1 + n, 1); return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0"); };
  const dismissFinding = (empId, code, reason, opt) => {
    const o = opt || {};
    const until = o.until || addMonths(periodYm, o.months != null ? o.months : DISMISS_MONTHS);
    setDismissals((d) => ({ ...d, [empId]: [...(d[empId] || []), { code, reason: reason || "", by: user.name, at: nowStamp(), from: periodYm, until }] }));
    return until;
  };
  // B10 전월 스냅샷 — 실제 전월 로스터를 돌린 결과. 전월에 없던 사람은 present:false → NEW_HIRE가 실제로 발화한다(D1).
  const prevRunOf = (c) => {
    const B = window.BARUNSEM, pym = B.prevYm(period.ym);
    const base = baseOf(c.id); if (!base) return {};
    const prevRoster = B.rosterFor(base, pym, commitStore);
    const rs = rulesetFor(c.id);
    const out = {};
    prevRoster.employees.forEach((e) => {
      const seeded = e.prevRun;                                   // 시드 귀속월엔 전월 실측 대신 시드 스냅샷 사용
      if (pym < SEED_YM && seeded) { out[e.id] = seeded; return; }
      const r = B.runFor(e, prevRoster, pym, rs);
      out[e.id] = { gross: r.gross, net: r.net, commonKeys: r.earnings.filter((x) => x.common).map((x) => x.name), present: true };
    });
    return out;
  };
  const clientAudit = (c) => {
    const rs = rulesetFor(c.id);
    const rb = window.BARUNSEM.RULEBOOK;
    // D5 — 검수 룰이 사업장 근태 규칙을 실제로 읽도록 _client를 주입한다(전엔 항상 법정 디폴트였다).
    // 만료됐거나 다른 귀속월에 건 끄기는 되살린다 — 무기한 침묵은 검수를 형해화한다(C9).
    const live = (d) => !d.until || d.until >= period.ym;
    const emps = c.employees.map((e) => ({ ...e, _client: c, dismissed: [...(e.dismissed || []), ...((dismissals[e.id] || []).filter(live))] }));
    const prevAll = prevRunOf(c);
    const runsByEmp = {}, prevByEmp = {};
    emps.forEach((e) => {
      runsByEmp[e.id] = window.BARUNSEM.runFor(e, c, period.ym, rs);
      prevByEmp[e.id] = prevAll[e.id] || (period.ym === SEED_YM ? (e.prevRun || null) : { present: false });
    });
    const res = window.BARUNSEM.auditClient({ ...c, employees: emps }, runsByEmp, prevByEmp, rs, rb, statusOf(c.id) !== "draft", { ym: period.ym });
    // W1-9 — 근로자 이의제기는 검수 큐의 일급 항목이다. 별도 사일로에 두면 아무도 안 본다.
    disputesOf(c.id).filter((d) => d.status === "open" && d.ym === period.ym)
      .forEach((d) => res.findings.push({ empId: d.empId, empName: d.empName, code: "DISPUTE", severity: "error",
        message: `근로자 이의제기 — “${d.body}”`, hint: "명세서 항목 확인 후 정정 또는 사유 회신 (접수 " + d.at + ")", disputeId: d.id }));
    const sev = (x) => res.findings.filter((f) => f.severity === x).length;
    return { ...res, errors: sev("error"), warns: sev("warn"), infos: sev("info") };
  };

  /* ── W3-5 해시 라우팅 ─────────────────────────────────
     새로고침하면 대시보드로 리셋되고 뒤로가기·링크 공유가 안 되던 자리(E10).
     #/client/:cid/:tab?ym=  ·  #/dashboard?ym=  형태로 상태를 URL에 싣는다. */
  const hashFor = () => {
    if (CLIENT_VIEWS.includes(view)) return `#/client/${clientId}/${view}?ym=${periodYm}`;
    // 고객사 포털·근로자 채널도 '누구의 화면인지'가 URL에 있어야 새로고침·링크 공유가 성립한다.
    if (view === "clientform") return `#/clientform?ym=${periodYm}&cid=${clientId}`;
    if (view === "worker") return `#/worker?ym=${periodYm}&cid=${clientId}&emp=${workerId}`;
    return `#/${view}?ym=${periodYm}`;
  };
  useEffect(() => {
    const apply = () => {
      const m = /^#\/([^?]*)(?:\?(.*))?$/.exec(window.location.hash || "");
      if (!m) return;
      const parts = m[1].split("/").filter(Boolean);
      const qs = new URLSearchParams(m[2] || "");
      const ym = qs.get("ym");
      if (ym && PERIODS.some((p) => p.ym === ym)) setPeriodYm(ym);
      if (parts[0] === "client" && parts[1] && BASE_CLIENTS.some((c) => c.id === parts[1])) {
        setClientId(parts[1]);
        setView(CLIENT_VIEWS.includes(parts[2]) ? parts[2] : "review");
      } else if (ROOT_VIEWS.includes(parts[0])) {
        const cid = qs.get("cid"), emp = qs.get("emp");
        const c = cid && BASE_CLIENTS.find((x) => x.id === cid);
        if (c) setClientId(cid);
        // 근로자 채널은 workerId가 화면을 정한다 — cid만 바꾸면 아무 일도 안 일어난다.
        if (emp) setWorkerId(emp);
        else if (c && parts[0] === "worker" && c.employees.length) setWorkerId(c.employees[0].id);
        setView(parts[0]);
      }
    };
    apply();
    window.addEventListener("hashchange", apply);
    return () => window.removeEventListener("hashchange", apply);
  }, []);
  useEffect(() => {
    // 가드 없이 '값이 다를 때만' 쓴다. rAF로 푸는 플래그를 쓰면 탭이 백그라운드일 때 rAF가 밀려
    // 플래그가 true로 고착되고, 그 뒤로는 화면만 바뀌고 URL이 영영 따라오지 않는다.
    const h = hashFor();
    if (window.location.hash !== h) window.location.hash = h;   // 히스토리에 쌓여 뒤로가기가 동작한다
  }, [view, clientId, periodYm]);

  const go = (v, params) => {
    if (params && params.clientId) setClientId(params.clientId);
    if (params && params.empId) setEmpId(params.empId);
    setSwitcherOpen(false);
    setNavOpen(false);
    setView(v);
    document.querySelector(".scroll")?.scrollTo(0, 0);
  };

  // §08-B 임직원 셀프서비스 — 증명서 발급·연말정산 제출·휴가 신청 (append-only, worker↔yearend 공유)
  const [selfService, setSelfService] = useState({});
  const ssOf = (empId) => selfService[empId] || { certs: [], leaveReqs: [], ye: null, disputes: [] };
  const addCertRequest = (empId, type) => setSelfService((s) => { const cur = s[empId] || { certs: [], leaveReqs: [], ye: null }; return { ...s, [empId]: { ...cur, certs: [...cur.certs, { id: "cert-" + Math.random().toString(36).slice(2, 6), type, at: nowStamp(), status: "issued" }] } }; });
  const addLeaveRequest = (empId, req) => setSelfService((s) => { const cur = s[empId] || { certs: [], leaveReqs: [], ye: null }; return { ...s, [empId]: { ...cur, leaveReqs: [...cur.leaveReqs, { id: "lv-" + Math.random().toString(36).slice(2, 6), status: "pending", ...req }] } }; });
  /* W1-9 근로자 이의제기 — 토스트가 아니라 기록이다. 담당자 인박스와 검수 플래그로 흘러간다(B11). */
  const addDispute = (empId, clientId, body) => setSelfService((s) => {
    const cur = s[empId] || { certs: [], leaveReqs: [], ye: null, disputes: [] };
    return { ...s, [empId]: { ...cur, disputes: [{ id: "dp-" + Math.random().toString(36).slice(2, 6), at: nowStamp(), status: "open", clientId, ...body }, ...(cur.disputes || [])] } };
  });
  const resolveDispute = (empId, id, memo) => setSelfService((s) => {
    const cur = s[empId] || {};
    return { ...s, [empId]: { ...cur, disputes: (cur.disputes || []).map((d) => d.id === id ? { ...d, status: "resolved", resolvedAt: nowStamp(), resolvedBy: user.name, memo } : d) } };
  });
  const disputesOf = (cid) => Object.entries(selfService).flatMap(([empId, v]) => (v.disputes || []).filter((d) => !cid || d.clientId === cid).map((d) => ({ ...d, empId })));
  const submitYearEnd = (empId, items) => setSelfService((s) => { const cur = s[empId] || { certs: [], leaveReqs: [], ye: null }; return { ...s, [empId]: { ...cur, ye: { submitted: true, at: nowStamp(), items } } }; });

  // §08-A 스코프 — 권한(viewClient) 경계 × 보기 스코프(내 담당/우리 팀/전사)
  const inScope = (c) => {
    if (!CAN.viewClient(user, c)) return false;
    if (scope === "mine") return [c.primaryAssigneeId, c.backupAssigneeId, ...(c.staffIds || [])].includes(user.id);
    if (scope === "team") return c.teamId === user.teamId;
    return true;
  };
  const scopedClients = clients.filter(inScope);
  const approvalQueue = scopedClients.filter((c) => statusOf(c.id) === "pending_approval" && CAN.approve(user, c));

  /* E6 알림 — 종 아이콘이 아무 일도 하지 않던 자리. 실제로 지금 내가 처리해야 할 것만 모은다. */
  const notifications = [
    ...approvalQueue.map((c) => ({ id: "ap-" + c.id, icon: "approval", tone: "warn", title: "승인 요청", body: `${c.short} · ${period.label} 급여 확정 승인 대기`, go: () => go("documents", { clientId: c.id }) })),
    ...scopedClients.filter((c) => statusOf(c.id) === "submitted").map((c) => ({ id: "sb-" + c.id, icon: "cloud_done", tone: "accent", title: "변동분 도착", body: `${c.short} · 검수 대기`, go: () => go("review", { clientId: c.id }) })),
    ...scopedClients.flatMap((c) => rejectionsOf(c.id).filter((r) => !r.resolved).map((r) => ({ id: "rj-" + c.id + r.id, icon: "assignment_return", tone: "danger", title: "반려 미해결", body: `${c.short} · ${r.reason}`, go: () => go("review", { clientId: c.id }) }))),
    ...disputesOf().filter((d) => d.status === "open").map((d) => ({ id: "dp-" + d.id, icon: "record_voice_over", tone: "danger", title: "근로자 이의제기", body: `${d.empName} · ${d.body}`, go: () => go("review", { clientId: d.clientId }) })),
  ];

  /* ── ctx — 화면에 넘기는 단일 객체. 필드가 50개를 넘어가면 무엇이 어디 소속인지 알 수 없으므로
     역할별로 묶어 읽는다. 형태는 평평하게 유지한다(기존 화면 호출부 보존). */
  const ctx = {
    // 셸 · 라우팅
    t, setTweak, go, toast, period, PERIODS, periodYm, setPeriodYm, params: { clientId, empId }, SEED_YM,
    // 로스터 — 그 달의 명부 + 원본 시드 접근
    clients, baseOf, clientTotals,
    // 워크플로 — 상태 전이는 transition/gateFor 하나로만 나간다(setStatus는 노출하지 않는다)
    statusOf, transition, gateFor, nextTransitions, openErrorsOf, addAudit, auditOf,
    submitForApproval, approveClient, rejectApproval, unlockForRecalc,
    rejectionOf, rejectionsOf, rejectClient, resubmitClient,
    // 앞단 커밋 — 고객사 제출 · 계약 · 인사변동 · 근태 매핑
    submitVariation, variationOf, saveContract, contractOf,
    hrChangesOf, addHrChange, removeHrChange,
    attendanceMaps, saveAttendanceMap,
    attendanceDraftOf: (cid) => attendanceDrafts[cid] || {},
    setAttendanceDraft: (cid, patch) => setAttendanceDrafts((s) => ({ ...s, [cid]: { ...(s[cid] || {}), ...patch } })),
    // 검수
    clientAudit, dismissFinding, dismissals,
    // 룰셋 거버넌스 (법인 오버레이)
    gov, adoptStandard, saveOverride, removeOverride, appliedRuleset, snapOf, snapHistoryOf, stampRuleset, rsGov: G,
    stdVersions, stdVer, stdStatusOf, rulesetFor, recalcNeeded, recompute, runsForStd, effStdId,
    diffStdLive, resolveFirmLive, frozenConfirmed, recalcClients,
    // 플랫폼 표준 (별도 권한 — 법인 owner의 일이 아니다)
    platformMode, setPlatformMode, canPublishStandard, canManageTenants,
    publishStandard, adminRunsForStd, tenants, toggleTenant, latestIncomingId,
    // 조직 · 권한
    role, user, actorId, setActorId, users: USERS, userOf, teamOf, can: CAN, ROLE_LABEL, canEditRuleset,
    scope, setScope, scopedClients, approvalQueue,
    canReview: CAN.review(user), canApprove: (c) => CAN.approve(user, c), canDistribute: CAN.distribute(user),
    // 셀프서비스 (근로자 채널)
    workerId, setWorkerId, ssOf, addCertRequest, addLeaveRequest, submitYearEnd,
    addDispute, resolveDispute, disputesOf, selfService,
    // 청구
    planOf, savePlan, invoices, markPaid, applyHealthSettlement,
  };

  const needReview = scopedClients.filter((c) => ["submitted", "in_review"].includes(statusOf(c.id))).length;

  const Screen = { dashboard: Dashboard, review: Review, calc: Calc, documents: Documents, statutory: Statutory, filings: Filings, yearend: YearEnd, billing: Billing, contract: Contract, ruleset: Ruleset, clientform: ClientForm, worker: Worker }[view];

  // 고객사 폼·근로자 명세서는 전체화면(별도 채널)
  // W2-5 — 고객사·근로자 채널은 '미리보기'다. 노무사용 Tweaks 패널을 여기서 띄우면 채널 경계가 무너진다.
  if (view === "clientform") return (<><div className="channel-enter" key="ch-clientform" ref={playEnterRef}><ClientForm ctx={ctx} /></div><ToastWrap toasts={toasts} /></>);
  if (view === "worker") return (<><div className="channel-enter" key="ch-worker" ref={playEnterRef}><Worker ctx={ctx} /></div><ToastWrap toasts={toasts} /></>);

  const activeClient = clients.find((c) => c.id === clientId);
  const crumbLabel = { dashboard: "대시보드", ruleset: "룰셋 (법규)", billing: "청구·매출" }[view] || "대시보드";
  const isClientView = CLIENT_VIEWS.includes(view);
  const navClients = scopedClients.length ? scopedClients : clients;
  const clientIdx = navClients.findIndex((c) => c.id === clientId);
  const q = clientQuery.trim();
  const filteredClients = q ? scopedClients.filter((c) => c.name.includes(q) || (c.short && c.short.includes(q))) : scopedClients;
  const selectClient = (cid) => { const tv = CLIENT_VIEWS.includes(view) ? view : defaultTabFor(statusOf(cid)); setSwitcherOpen(false); go(tv, { clientId: cid }); };
  const gotoClient = (delta) => { const base = navClients.length ? navClients : clients; const i = clientIdx < 0 ? 0 : clientIdx; const n = base[(i + delta + base.length) % base.length]; setSwitcherOpen(false); go(view, { clientId: n.id }); };

  return (
    <div className={"app" + (navOpen ? " nav-open" : "")}>
      <div className="nav-scrim" onClick={() => setNavOpen(false)} />
      {/* 사이드바 */}
      <aside className="side">
        <div className="side-brand">
          <div className="brand-mark">바</div>
          <div><div className="brand-name">바른셈</div><div className="brand-sub">급여 워크스페이스</div></div>
        </div>
        <nav className="nav">
          <div className="nav-label">업무</div>
          <button className={"nav-item" + (view === "dashboard" ? " active" : "")} onClick={() => go("dashboard")}>
            <Ms name="dashboard" />대시보드
            {needReview > 0 && <span className="badge"><span className="pill pill-warn" style={{ height: 19, fontSize: 11, padding: "0 7px" }}>{needReview}</span></span>}
          </button>
          <button className={"nav-item" + (view === "billing" ? " active" : "")} onClick={() => go("billing")}><Ms name="request_quote" />청구·매출</button>

          <div className="nav-label with-count">고객사 <span className="lbl-count">{scopedClients.length}</span></div>
          <ScopeSwitch scope={scope} setScope={setScope} user={user} counts={{ mine: clients.filter((c) => CAN.viewClient(user, c) && [c.primaryAssigneeId, c.backupAssigneeId, ...(c.staffIds || [])].includes(user.id)).length, team: clients.filter((c) => CAN.viewClient(user, c) && c.teamId === user.teamId).length, firm: clients.filter((c) => CAN.viewClient(user, c)).length }} />
          <div className="cli-search"><Ms name="search" /><input value={clientQuery} onChange={(e) => setClientQuery(e.target.value)} placeholder="고객사 검색" /></div>
          <div className="cli-roster">
            {filteredClients.length === 0 && <div className="empty-row">검색 결과 없음</div>}
            {filteredClients.map((c) => {
              const cs = statusOf(c.id);
              const active = isClientView && c.id === clientId;
              const needs = ["submitted", "in_review"].includes(cs);
              return (
                <button key={c.id} className={"cli-item" + (active ? " active" : "")} onClick={() => selectClient(c.id)}>
                  <ClientAvatar client={c} size={28} />
                  <div className="cli-item-main">
                    <div className="cli-item-name">{c.name}</div>
                    <div className="cli-item-sub"><span className="sdot" style={{ background: STATUS_TONE[cs] }} />{STATUS_META[cs] ? STATUS_META[cs].label : cs}</div>
                  </div>
                  {needs && <span className="cli-item-badge" />}
                </button>
              );
            })}
          </div>

          <div className="nav-label">설정</div>
          <button className={"nav-item" + (view === "ruleset" ? " active" : "")} onClick={() => go("ruleset")}><Ms name="gavel" />룰셋 (법규)</button>
        </nav>
        <div className="side-foot">
          <button className="nav-item" onClick={() => go("clientform")}><Ms name="open_in_new" />고객사 입력화면</button>
          <button className="nav-item" onClick={() => go("worker")}><Ms name="smartphone" />근로자 명세서 (모바일)</button>
          <div className="user-chip">
            <div className="avatar">{user.avatar}</div>
            <div style={{ flex: 1, minWidth: 0 }}><div style={{ fontWeight: 650, fontSize: 13, display: "flex", alignItems: "center", gap: 6 }}>{user.name}<span className="tag" style={{ height: 16, fontSize: 10, padding: "0 5px" }}>{ROLE_LABEL[user.roles[0]]}</span></div><div className="muted" style={{ fontSize: 11.5 }}>{user.sub}</div></div>
            <button className="iconbtn" aria-label="내 계정 · 역할 전환" title="내 계정 · 역할 전환"
              style={{ width: 28, height: 28 }} onClick={() => setAccountOpen(true)}>
              <Ms name="settings" style={{ color: "var(--ink-4)" }} />
            </button>
          </div>
          <div className="copyright">© 2026 바른셈 · 무단 복제·도용 금지<br />All rights reserved.</div>
        </div>
      </aside>

      {/* 메인 */}
      <div className="main">
        <header className="topbar">
          <button className="iconbtn nav-toggle" onClick={() => setNavOpen(true)} title="메뉴"><Ms name="menu" /></button>
          <div className="crumbs">
            <Ms name="apartment" style={{ fontSize: 18, color: "var(--ink-4)" }} />
            {isClientView ? <b>고객사</b> : <b>{crumbLabel}</b>}
          </div>
          <div className="topbar-spacer" />
          <RoleToggle users={USERS} actorId={actorId} setActorId={setActorId} ROLE_LABEL={ROLE_LABEL} approvalQueue={approvalQueue} go={go} />
          <PeriodPicker periods={PERIODS} value={periodYm} onChange={setPeriodYm} />
          <button className="iconbtn" aria-label={`알림 ${notifications.length}건`} title="알림" aria-expanded={notifOpen} onClick={() => setNotifOpen(!notifOpen)} style={{ position: "relative" }}>
            <Ms name="notifications" />
            {notifications.length > 0 && <span className="dot-badge" aria-hidden="true">{notifications.length}</span>}
          </button>
          {notifOpen && <NotifPanel items={notifications} onClose={() => setNotifOpen(false)} go={go} />}
        </header>
        {isClientView && activeClient && (
          <WorkspaceHeader client={activeClient} status={statusOf(activeClient.id)} view={view} go={go}
            clients={navClients} clientIdx={clientIdx} gotoClient={gotoClient} selectClient={selectClient}
            user={user} userOf={userOf} teamOf={teamOf}
            statuses={statusOf} period={period} switcherOpen={switcherOpen} setSwitcherOpen={setSwitcherOpen} />
        )}
        <div className="scroll">
          <div className="view-host" key={view + ":" + (isClientView ? clientId : "")} ref={playEnterRef}>
            {Screen ? <Screen ctx={ctx} /> : null}
          </div>
        </div>
      </div>

      {accountOpen && (
        <Modal title="내 계정" onClose={() => setAccountOpen(false)}
          footer={<button className="btn btn-default" onClick={() => setAccountOpen(false)}>닫기</button>}>
          <div className="dl" style={{ fontSize: 13, marginBottom: 14 }}>
            <dt>이름</dt><dd>{user.name}</dd>
            <dt>역할</dt><dd>{ROLE_LABEL[user.roles[0]]}</dd>
            <dt>소속</dt><dd>{user.sub}</dd>
            <dt>팀</dt><dd>{(teamOf(user.teamId) || {}).name || "—"}</dd>
          </div>
          <div style={{ fontSize: 11.5, fontWeight: 700, color: "var(--ink-2)", marginBottom: 7 }}>역할 전환 (데모)</div>
          <div style={{ display: "flex", gap: 7, flexWrap: "wrap", marginBottom: 14 }}>
            {USERS.map((u) => (
              <button key={u.id} className={"btn btn-sm " + (u.id === actorId ? "btn-primary" : "btn-default")}
                onClick={() => { setActorId(u.id); setAccountOpen(false); toast(`${u.name}(${ROLE_LABEL[u.roles[0]]})으로 전환했습니다`, "ok"); }}>
                {u.name} · {ROLE_LABEL[u.roles[0]]}
              </button>
            ))}
          </div>
          <div style={{ fontSize: 11.5, fontWeight: 700, color: "var(--ink-2)", marginBottom: 7 }}>알림</div>
          <div className="muted" style={{ fontSize: 12.5, lineHeight: 1.6 }}>
            승인 요청 · 변동분 도착 · 반려 · 근로자 이의제기는 상단 종 아이콘에 모입니다. 현재 {notifications.length}건.
          </div>
        </Modal>
      )}
      <ToastWrap toasts={toasts} />
      <Tweaks t={t} setTweak={setTweak} />
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
