// ผังองค์กร — แสดงโครงสร้างองค์กร สายงาน → ฝ่าย → แผนก (เป็นหลัก)
// และวางพนักงานไว้ใต้หน่วยงานที่สังกัด (ถ้ามี). แสดงโครงสร้างได้แม้ยังไม่มีพนักงาน
// พอร์ตจาก OKR Evaluation System — EmpInfo ยังไม่มีสายบังคับบัญชา solid/dotted (Phase 1)
// จึงจัดกลุ่มพนักงานตาม business_line/division/department FK เท่านั้น (relationships = [] เสมอ)
// คลิกการ์ดพนักงานจะเปิด EditEmployeeForm (จาก forms.jsx) แทนที่จะมีฟอร์มแยกของตัวเอง

const OC_PALETTE = ["#4338ca", "#db2777", "#7c3aed", "#0e7490", "#ea580c", "#16a34a", "#b45309"];
// สีกลางสำหรับชั้นบน (ประธานบริหาร/ผู้อำนวยการ) — สายงานแต่ละอันได้สีจาก OC_PALETTE แยกกัน
const OC_TOP_COLOR = "#334155";

// ส่ง callback "คลิกการ์ดพนักงาน (empId)" ให้ทุกกล่อง/การ์ดในผัง โดยไม่ต้องส่ง prop ไล่ทีละชั้น
// OrgDiagramScreen เป็นผู้ provide เพื่อเปิดฟอร์มแก้ไขพนักงานเมื่อคลิก
const EmpClickContext = React.createContext(null);

function ocInitial(emp) {
  const s = (emp.first_name || emp.full_name || "?").trim();
  return s.charAt(0) || "?";
}

// ตัดคำนำหน้าชื่อ (นาย/นาง/นางสาว) ออกจากชื่อที่แสดงในกล่องผังองค์กร — เช็ค "นางสาว" ก่อน "นาง"
// กันตัดผิด (นางสาว ขึ้นต้นด้วย "นาง" เหมือนกัน). full_name เก็บคำนำหน้าไว้ด้วยเสมอ (ใช้ที่อื่น เช่น
// หน้ารายชื่อพนักงาน) จึงตัดตอนแสดงผลในผังแทน ไม่แก้ข้อมูลต้นทาง
const OC_NAME_PREFIXES = ["นางสาว", "นาย", "นาง"];
function ocDisplayName(fullName) {
  const s = (fullName || "").trim();
  for (const p of OC_NAME_PREFIXES) {
    if (s.startsWith(p + " ")) return s.slice(p.length + 1).trim();
  }
  return s;
}

// วงกลมประจำตัว — ใช้รูปจริงถ้ามี (photo_data) ไม่งั้น fallback เป็นตัวอักษรแรกของชื่อบนพื้นสี
function Avatar({ photoData, initial, color, size }) {
  const className = "oc-avatar" + (size ? " oc-avatar--" + size : "");
  if (photoData) return <div className={className} style={{ backgroundImage: `url(${photoData})` }} />;
  return <div className={className} style={{ background: color }}>{initial}</div>;
}

// สร้าง node tree จากโครงสร้างหน่วยงาน
// (ประธานบริหาร -> ผู้อำนวยการ -> สายงาน -> ฝ่าย -> แผนก)
// แล้ววางพนักงานใต้หน่วยที่ตรงลึกสุด
function buildOrgChart(structure, employees, relationships) {
  const presidents = (structure && structure.presidents) || [];
  const unassignedLines = (structure && structure.unassignedBusinessLines) || [];
  const emps = employees || [];
  const used = new Set();
  const empNode = (e, color, children) => ({ kind: "emp", id: "e" + e.id, emp: e, color, children: children || [] });

  // ระดับของตำแหน่ง (จากบันไดตำแหน่ง) — ใช้เรียงคนตำแหน่งเดียวกันให้อยู่ระนาบเดียวกัน
  const posLevel = {};
  for (const p of (structure && structure.positions) || []) posLevel[p.name] = p.level;
  const levelOf = (e) => (e.position != null && posLevel[e.position] != null) ? posLevel[e.position] : 9999;
  const byRank = (a, b) => (levelOf(a) - levelOf(b)) || (a.full_name || "").localeCompare(b.full_name || "");
  // บันไดตำแหน่งทั้งหมด (level เรียงจากสูงไปต่ำ) — ใช้เติม "ชั้นว่าง" ให้ rank ที่ขาดหายใน chainByRank
  const ladderLevels = [...new Set(Object.values(posLevel))].sort((a, b) => a - b);
  const ladderSet = new Set(ladderLevels);

  // ผู้บังคับบัญชาสายตรง (solid) ของแต่ละคน — EmpInfo Phase 1 ยังไม่มี relationships จึงว่างเสมอ
  // (ทุกคนกลายเป็น "orphan" ในความหมายของ resolveOrphans ด้านล่าง = จัดกลุ่มตาม rank ใต้หน่วยงานเฉยๆ)
  const solidManagerOf = {};
  for (const r of (relationships || [])) {
    if (r.type === "solid") solidManagerOf[r.employee_id] = r.manager_id;
  }

  // จัดรายการ "ลูก" ของหน่วยงานหนึ่ง (กล่องหน่วยงานย่อย + การ์ดพนักงาน — ผสมกันได้) ตามระดับ
  // ตำแหน่ง (rank) แล้วร้อยต่อกันด้วยตัวเชื่อมล่องหน (spacer) เพื่อให้ตำแหน่งสูง-ต่ำ อยู่คนละ
  // ระนาบความสูงเสมอ ไม่ว่าจะเป็นกล่องหน่วยงาน (ยศ = ตำแหน่งหัวหน้าที่ฝังอยู่ในกล่อง) หรือการ์ด
  // พนักงานเดี่ยว ๆ ก็ตาม — items: [{ rank, node }]
  //
  // baseRank (ถ้าระบุ) = ยศของหัวหน้า scope นี้ — จะเติม "ชั้นว่าง" ให้ทุก rank ในบันไดที่อยู่ระหว่าง
  // baseRank กับ rank ลึกสุดของ items แม้ชั้นนั้นไม่มีใครอยู่เลย เพื่อดันตำแหน่งล่างให้ตกไปอยู่ระนาบ
  // เดียวกับตำแหน่งเดียวกันในสายอื่นที่มีชั้นกลางครบ (เช่น หน.แผนก ใต้ฝ่ายที่ไม่มีกล่องแผนก ต้องอยู่
  // ระนาบเดียวกับ หน.แผนก ที่อยู่ใต้กล่องแผนกของฝ่ายอื่น) — ชั้นว่างเป็น spacer ลากเส้นตรงผ่านลงมา
  function chainByRank(items, scopeId, baseRank) {
    const tierMap = new Map();
    for (const it of items) {
      if (!tierMap.has(it.rank)) tierMap.set(it.rank, []);
      tierMap.get(it.rank).push(it.node);
    }
    let levels = [...tierMap.keys()].sort((a, b) => a - b);
    if (baseRank != null && levels.length) {
      // เติมเฉพาะช่วงถึง rank ลึกสุดที่อยู่ในบันไดจริง (กัน rank fallback 9999 ทำให้เติมชั้นเกินจำเป็น)
      const real = levels.filter((l) => ladderSet.has(l));
      if (real.length) {
        const maxReal = real[real.length - 1];
        const padded = ladderLevels.filter((l) => l > baseRank && l <= maxReal);
        levels = [...new Set([...padded, ...levels])].sort((a, b) => a - b);
      }
    }
    // rank ที่ถือเป็น "ชั้นกล่องหน่วยงาน" — spacer ที่แทนชั้นว่างระดับนี้ต้องสูงเท่ากล่องหน่วยงาน
    const unitRanks = new Set([HEAD_RANK.president, HEAD_RANK.director, HEAD_RANK.line, HEAD_RANK.division, HEAD_RANK.department]);

    let topLevelNodes = [];
    let currentRow = null; // spacer ที่ถือชั้นถัดไป (พี่น้องของชั้นก่อนหน้า ไม่ใช่ลูกของคนใดคนหนึ่ง)
    levels.forEach((lvl, i) => {
      const nodes = tierMap.get(lvl) || []; // ชั้นว่าง (เติมจากบันได) = ไม่มี node เลย มีแต่ spacer พาดผ่าน
      if (i === 0) topLevelNodes = [...nodes];
      else currentRow.children.push(...nodes);

      if (i < levels.length - 1) {
        const spacer = { kind: "spacer", id: `sp-${scopeId}-${lvl}`, children: [],
          // ชั้นว่าง: บอกชนิดความสูงที่ spacer ต้องแทน (กล่องหน่วยงาน vs การ์ดพนักงาน) ให้ equalizeSpacers ใช้
          standIn: nodes.length ? null : (unitRanks.has(lvl) ? "unit" : "emp") };
        if (i === 0) topLevelNodes.push(spacer);
        else currentRow.children.push(spacer);
        currentRow = spacer;
      }
    });
    return topLevelNodes;
  }

  // resolve ความสัมพันธ์หัวหน้า-ลูกน้องภายในกลุ่มคนที่ไม่มีหัวหน้าเป็นหน่วยงานที่รู้จัก (orphan
  // ต่อกันเอง เช่น หัวหน้าแผนกดูแลเจ้าหน้าที่) แล้วคืนรากของแต่ละกลุ่มพร้อม rank ของราก
  // (ไม่จัดชั้น/ไม่ใส่ spacer เอง — caller เป็นคนรวมกับรายการอื่นแล้วส่งเข้า chainByRank ทีเดียว)
  function resolveOrphans(list, color) {
    if (!list.length) return [];
    const ids = new Set(list.map((e) => e.id));
    const childrenOf = new Map();
    const roots = [];
    for (const e of list) {
      const mgr = solidManagerOf[e.id];
      if (mgr && ids.has(mgr)) {
        if (!childrenOf.has(mgr)) childrenOf.set(mgr, []);
        childrenOf.get(mgr).push(e);
      } else {
        roots.push(e);
      }
    }
    const seen = new Set();
    const build = (e) => {
      if (seen.has(e.id)) return null; // กันลูป
      seen.add(e.id);
      used.add(e.id); // กันไม่ให้ไปโผล่ซ้ำใน "ยังไม่ระบุหน่วยงาน" ท้ายสุด
      const kids = (childrenOf.get(e.id) || []).sort(byRank).map(build).filter(Boolean);
      return empNode(e, color, kids);
    };
    return roots.map((e) => ({ rank: levelOf(e), node: build(e) })).filter((x) => x.node);
  }

  // เดิม nestEmps ใช้กับพนักงานล้วน ๆ (ยังใช้กับ "ยังไม่ระบุหน่วยงาน" ที่ไม่มีกล่องหน่วยงานปน)
  function nestEmps(list, color, scopeId) {
    return chainByRank(resolveOrphans(list, color), scopeId || color);
  }

  // ตัวนับสีสายงาน — สายงานแต่ละอันได้สีต่างกัน (ทั่วทั้งผัง ไม่ว่าอยู่ใต้ผู้อำนวยการคนไหน)
  let lineColorIdx = 0;
  const nextLineColor = () => OC_PALETTE[lineColorIdx++ % OC_PALETTE.length];

  // ตำแหน่งที่ถือว่าเป็น "หัวหน้าหน่วยงาน" ของแต่ละระดับ — ถ้ามีคนตำแหน่งนี้ในหน่วยงานนั้น
  // จะเอาชื่อไปใส่ในกล่องหน่วยงานเลย แทนที่จะแสดงเป็นการ์ดพนักงานแยก
  const HEAD_POSITION = {
    president: "ประธานบริหาร", director: "ผู้อำนวยการ", line: "รองผู้อำนวยการ",
    division: "ผู้จัดการฝ่าย", department: "ผู้จัดการแผนก",
  };
  // ยศของกล่องหน่วยงานแต่ละระดับ = ระดับตำแหน่งของ "หัวหน้าหน่วยงาน" ที่ควรฝังอยู่ในกล่องนั้น
  // ใช้เทียบกับยศพนักงานเดี่ยว ๆ เพื่อจัดระนาบความสูงในผังให้ถูกต้อง (กล่องแผนกต้องอยู่สูงกว่า
  // การ์ดหัวหน้าแผนกเสมอ แม้จะเป็นพี่น้องกันในโครงสร้าง DOM ก็ตาม)
  const HEAD_RANK = {
    president: posLevel[HEAD_POSITION.president] != null ? posLevel[HEAD_POSITION.president] : 9999,
    director: posLevel[HEAD_POSITION.director] != null ? posLevel[HEAD_POSITION.director] : 9999,
    line: posLevel[HEAD_POSITION.line] != null ? posLevel[HEAD_POSITION.line] : 9999,
    division: posLevel[HEAD_POSITION.division] != null ? posLevel[HEAD_POSITION.division] : 9999,
    department: posLevel[HEAD_POSITION.department] != null ? posLevel[HEAD_POSITION.department] : 9999,
  };

  // ดึง "หัวหน้าหน่วยงาน" ออกจากบนลงล่าง (สายงาน -> ฝ่าย -> แผนก) ตามตำแหน่งเป็นหลัก
  // โดยไม่สนว่ามีข้อมูลช่องที่ต่ำกว่าติดมาด้วยหรือไม่ — เช่น ผู้จัดการฝ่ายที่บังเอิญมีช่อง "แผนก"
  // ถูกกรอกไว้ด้วย ก็ยังถูกดึงไปเป็นหัวหน้าฝ่าย ไม่ใช่การ์ดที่โผล่อยู่ในแผนก (ต้องดึงก่อนวนลูปแผนก)
  //
  // จากนั้นคนที่เลือก "หัวหน้า" ที่ถูกดึงไปโชว์ในกล่อง (เช่น เลือกผู้จัดการฝ่ายเป็นหัวหน้าโดยตรง
  // ข้ามผู้จัดการแผนกไปเลย) จะถูกแนบเป็นลูกของกล่องหน่วยงานที่หัวหน้าคนนั้นสังกัด — ไม่ปนกับคนที่
  // ไม่มีหัวหน้าจริง (ซึ่งจะเข้ากลไก rank-tiering/spacer ตามเดิม)
  //
  // externalHeadIds = หัวหน้าที่อยู่ "นอกสายงานนี้" (เช่น ผู้อำนวยการที่คุมหลายสายงาน) — ถ้าใครใน
  // สายงานนี้เลือกคนกลุ่มนี้เป็นผู้บังคับบัญชา (ข้ามฝ่าย/สายงานไปเลย) จะไม่ถูกนับเป็น orphan ในนี้
  // แต่จะถูกดึงออกไปคืนเป็น externalReports ให้ผู้เรียก (buildOrgChart) ไปแนบไว้ที่กล่องที่ถูกต้อง
  // แทน (ระดับตำแหน่ง/ยศของคนนั้นยังคงเป็นของเขาเองเสมอ ไม่ถูกยกขึ้นไปเทียบเท่าหัวหน้าที่เลือก)
  function buildLine(line, color, externalHeadIds) {
    externalHeadIds = externalHeadIds || new Set();
    // จับคู่พนักงาน<->หน่วยงานด้วย id (business_line_id/division_id/department_id) ไม่ใช่ชื่อ
    // กันปัญหาหน่วยงานชื่อซ้ำกันแล้วแยกพนักงานออกจากกันไม่ได้
    const lineEmpsAll = emps.filter((e) => !used.has(e.id) && e.business_line_id === line.id);

    const lineHeads = lineEmpsAll.filter((e) => e.position === HEAD_POSITION.line);
    const divHeadsOf = {};
    const deptHeadsOf = {};
    for (const div of line.divisions || []) {
      divHeadsOf[div.id] = lineEmpsAll.filter((e) =>
        e.division_id === div.id && e.position === HEAD_POSITION.division);
      for (const dept of div.departments || []) {
        deptHeadsOf[dept.id] = lineEmpsAll.filter((e) =>
          e.division_id === div.id && e.department_id === dept.id && e.position === HEAD_POSITION.department);
      }
    }
    const headIds = new Set([
      ...lineHeads.map((e) => e.id),
      ...Object.values(divHeadsOf).flat().map((e) => e.id),
      ...Object.values(deptHeadsOf).flat().map((e) => e.id),
    ]);
    headIds.forEach((id) => used.add(id));

    // pool ของคนที่ไม่ใช่หัวหน้า — ใช้ resolve สายบังคับบัญชาข้ามหน่วยงานย่อยได้ในสายงานเดียวกัน
    const restPool = lineEmpsAll.filter((e) => !headIds.has(e.id));
    const restIds = new Set(restPool.map((e) => e.id));
    const seen = new Set();
    const build = (e) => {
      if (seen.has(e.id)) return null; // กันลูป
      seen.add(e.id);
      used.add(e.id);
      const kids = restPool.filter((k) => !seen.has(k.id) && solidManagerOf[k.id] === e.id)
        .sort(byRank).map(build).filter(Boolean);
      return empNode(e, color, kids);
    };
    // คนที่หัวหน้าที่เลือกไว้ตรงกับ id ใดใน headIds ชุดนี้ — แนบเป็นลูกของกล่องนั้นโดยตรง
    const reportsTo = (ids) => restPool.filter((e) => !seen.has(e.id) && ids.has(solidManagerOf[e.id]))
      .sort(byRank).map(build).filter(Boolean);
    // คนที่ไม่มีหัวหน้าที่ resolve ได้เลย (ไม่ใช่ head, manager ไม่อยู่ใน restPool, และไม่ได้ขึ้นตรง
    // กับหัวหน้านอกสายงานนี้ด้วย) = orphan จริง
    const isOrphan = (e) => {
      const mgr = solidManagerOf[e.id];
      return !mgr || (!headIds.has(mgr) && !restIds.has(mgr) && !externalHeadIds.has(mgr));
    };

    const divChildren = (line.divisions || []).map((div) => {
      const divHeadIds = new Set((divHeadsOf[div.id] || []).map((e) => e.id));

      // จัดคนเข้ากล่องแผนก โดย "เคารพสายบังคับบัญชาจริง":
      //  - แผนกที่มี ผจก.แผนก (หัวหน้ากล่อง): เฉพาะคนที่ขึ้นตรง ผจก.แผนก (หรือไม่มีหัวหน้า) อยู่ใต้กล่อง
      //    ส่วนคนที่ "ข้าม" ไปขึ้นตรง ผจก.ฝ่าย (เช่น หน.แผนก บางคนรายงาน ผจก.ฝ่าย โดยตรง) จะไปอยู่ระดับ
      //    ฝ่ายแทน (ไม่เอาไปแขวนใต้ ผจก.แผนก เพราะจะทำให้เข้าใจผิดว่ารายงาน ผจก.แผนก)
      //  - แผนกที่ไม่มี ผจก.แผนก (กล่องว่าง): รวมทุกคนในแผนกไว้ใต้กล่องว่างนั้น (กล่องเป็นแค่ที่รวมกลุ่ม)
      const deptChildren = (div.departments || []).map((dept) => {
        const deptHeadIds = new Set((deptHeadsOf[dept.id] || []).map((e) => e.id));
        const hasDeptHead = deptHeadIds.size > 0;
        const deptMembers = restPool.filter((e) => !seen.has(e.id) &&
          e.division_id === div.id && e.department_id === dept.id);
        const deptMemberIds = new Set(deptMembers.map((e) => e.id));
        const deptRoots = deptMembers.filter((e) => {
          const mgr = solidManagerOf[e.id];
          if (mgr && deptMemberIds.has(mgr)) return false; // หัวหน้าอยู่ในแผนกเดียวกัน → เป็นลูก ไม่ใช่ราก
          if (hasDeptHead) return !mgr || deptHeadIds.has(mgr); // มี ผจก.แผนก: เอาเฉพาะที่ขึ้นตรง ผจก.แผนก/ไม่มีหัวหน้า
          return true; // ไม่มี ผจก.แผนก: รวมทุกคน
        });
        const deptItems = deptRoots.sort(byRank).map(build).filter(Boolean)
          .map((n) => ({ rank: levelOf(n.emp), node: n }));

        return { kind: "unit", unitType: "department", id: "d" + dept.id, name: dept.name, color,
          headNames: (deptHeadsOf[dept.id] || []).map((e) => ({ id: e.id, full_name: e.full_name, photo_data: e.photo_data })),
          children: chainByRank(deptItems, "d" + dept.id, HEAD_RANK.department) };
      });

      // ระดับฝ่าย = คนที่ขึ้นตรง ผจก.ฝ่าย โดยตรง (รวมคนมีแผนกที่ "ข้าม" ผจก.แผนก มาขึ้นตรง ผจก.ฝ่าย)
      const reportingToDivHead = reportsTo(divHeadIds);
      const divOrphans = restPool.filter((e) => !seen.has(e.id) &&
        e.division_id === div.id && !e.department_id && isOrphan(e));

      const divItems = [
        ...deptChildren.map((n) => ({ rank: HEAD_RANK.department, node: n })),
        ...reportingToDivHead.map((n) => ({ rank: levelOf(n.emp), node: n })),
        ...resolveOrphans(divOrphans, color),
      ];

      return { kind: "unit", unitType: "division", id: "v" + div.id, name: div.name, color,
        headNames: (divHeadsOf[div.id] || []).map((e) => ({ id: e.id, full_name: e.full_name, photo_data: e.photo_data })),
        children: chainByRank(divItems, "v" + div.id, HEAD_RANK.division) };
    });

    const reportingToLineHead = reportsTo(new Set(lineHeads.map((e) => e.id)));
    const lineOrphans = restPool.filter((e) => !seen.has(e.id) &&
      e.business_line_id === line.id && !e.division_id && isOrphan(e));

    const lineItems = [
      ...divChildren.map((n) => ({ rank: HEAD_RANK.division, node: n })),
      ...reportingToLineHead.map((n) => ({ rank: levelOf(n.emp), node: n })),
      ...resolveOrphans(lineOrphans, color),
    ];

    // คนในสายงานนี้ (ระดับไหนก็ได้) ที่เลือกหัวหน้านอกสายงาน (เช่น ผู้อำนวยการ) เป็นผู้บังคับบัญชา
    // โดยตรง — ดึงออกมาต่างหาก ให้ buildOrgChart เอาไปแนบใต้กล่องของหัวหน้าคนนั้นแทน (ยศ/ระนาบ
    // ของเขายังคงเป็นของเขาเองผ่าน levelOf ตามปกติ ไม่ผูกกับกล่องที่ไปโผล่)
    const externalReports = restPool.filter((e) => !seen.has(e.id) && externalHeadIds.has(solidManagerOf[e.id]))
      .sort(byRank).map(build).filter(Boolean);

    const node = { kind: "unit", unitType: "line", id: "l" + line.id, name: line.name, color,
      headNames: lineHeads.map((e) => ({ id: e.id, full_name: e.full_name, photo_data: e.photo_data })),
      children: chainByRank(lineItems, "l" + line.id, HEAD_RANK.line) };
    return { node, externalReports };
  }

  // ผู้อำนวยการ = คนตำแหน่ง "ผู้อำนวยการ" ที่ระบุ director_id ตรงกับกล่องนี้ (ไม่ผูกกับสายงาน/ฝ่าย
  // ใดๆ เพราะอยู่เหนือสายงานทั้งหมดในกล่องนั้น) — ดึงออกก่อนเหมือน head ระดับอื่นๆ
  // ประธานบริหาร เช่นเดียวกัน — คนตำแหน่ง "ประธานบริหาร" ที่ระบุ president_id ตรงกับกล่องนี้
  const roots = presidents.map((pres) => {
    const presHeads = emps.filter((e) => !used.has(e.id) &&
      e.president_id === pres.id && e.position === HEAD_POSITION.president);
    presHeads.forEach((e) => used.add(e.id));

    const dirChildren = (pres.directors || []).map((dir) => {
      const dirHeads = emps.filter((e) => !used.has(e.id) &&
        e.director_id === dir.id && e.position === HEAD_POSITION.director);
      const dirHeadIds = new Set(dirHeads.map((e) => e.id));
      dirHeadIds.forEach((id) => used.add(id));

      // ส่ง dirHeadIds เข้าไปให้แต่ละสายงานรู้ว่าใครคือ "หัวหน้านอกสายงาน" ที่ยอมให้ข้ามชั้นไปขึ้นตรง
      // ได้ (เช่น ผจก.แผนกที่ไม่มี ผจก.ฝ่าย เลือกขึ้นตรง ผอ. เลย) แล้วรวบ externalReports ของทุก
      // สายงานใต้ผู้อำนวยการคนนี้มาเป็นลูกของกล่องผู้อำนวยการโดยตรง
      const lineResults = (dir.businessLines || []).map((line) => buildLine(line, nextLineColor(), dirHeadIds));
      const reportingToDirHead = lineResults.flatMap((r) => r.externalReports);
      const nodeByLineId = {};
      (dir.businessLines || []).forEach((line, i) => { nodeByLineId[line.id] = lineResults[i].node; });

      // กลุ่มสายงานที่ผู้ใช้ตั้งไว้ (ถ้ามี) — สายงานในกลุ่มเดียวกันยุบรวมเป็นกล่องเดียวในผัง
      // (กล่องกลุ่มเป็นแค่การจัดกลุ่มเพื่อการแสดงผล ไม่มีตำแหน่ง "หัวหน้ากลุ่ม" ของตัวเอง)
      const groupDefs = dir.groups || [];
      const groupNodes = groupDefs.map((g) => {
        const children = (dir.businessLines || [])
          .filter((l) => l.group_id === g.id).map((l) => nodeByLineId[l.id]).filter(Boolean);
        if (!children.length) return null;
        return { kind: "unit", unitType: "linegroup", id: "lg" + g.id, name: g.name, color: OC_TOP_COLOR, children };
      }).filter(Boolean);

      const groupIds = new Set(groupDefs.map((g) => g.id));
      const ungroupedLineNodes = (dir.businessLines || [])
        .filter((l) => !l.group_id || !groupIds.has(l.group_id))
        .map((l) => nodeByLineId[l.id]);

      const dirItems = [
        ...groupNodes.map((n) => ({ rank: HEAD_RANK.line, node: n })),
        ...ungroupedLineNodes.map((n) => ({ rank: HEAD_RANK.line, node: n })),
        ...reportingToDirHead.map((n) => ({ rank: levelOf(n.emp), node: n })),
      ];

      return { kind: "unit", unitType: "director", id: "dir" + dir.id, name: dir.name, color: OC_TOP_COLOR,
        headNames: dirHeads.map((e) => ({ id: e.id, full_name: e.full_name, photo_data: e.photo_data })),
        children: chainByRank(dirItems, "dir" + dir.id, HEAD_RANK.director) };
    });
    return { kind: "unit", unitType: "president", id: "p" + pres.id, name: pres.name, color: OC_TOP_COLOR,
      headNames: presHeads.map((e) => ({ id: e.id, full_name: e.full_name, photo_data: e.photo_data })), children: dirChildren };
  });

  // สายงานที่ยังไม่ได้กำหนดผู้อำนวยการ — แสดงเป็น root แยกต่างหาก (สีของตัวเองเช่นกัน)
  // ไม่มีผู้อำนวยการคุม จึงไม่มี externalHeadIds ให้ข้ามขึ้นไปเลย (ไม่มีที่ให้ไปเกาะ)
  unassignedLines.forEach((line) => {
    roots.push(buildLine(line, nextLineColor()).node);
  });

  // พนักงานที่ยังไม่ได้ระบุหน่วยงาน (ไม่ตรงสายงานใดเลย)
  const rest = emps.filter((e) => !used.has(e.id));
  if (rest.length) {
    roots.push({ kind: "unit", unitType: "none", id: "none", name: "ยังไม่ระบุหน่วยงาน", color: "#94a3b8",
      children: nestEmps(rest, "#94a3b8") });
  }
  return roots;
}

// จัดความสูงของ "ตัวเชื่อมล่องหน" (spacer) ให้เท่ากับกล่องพี่น้องที่สูงที่สุดในแถวเดียวกัน — เพื่อให้
// ชั้นล่างที่ห้อยอยู่ใต้ spacer (เช่น หัวหน้าแผนกที่ขึ้นตรงกับ ผจก.ฝ่าย ไม่ผ่านกล่องแผนก) ตกลงไป
// อยู่ "ใต้" แถวของกล่องพอดี ระนาบเดียวกับคนตำแหน่งเดียวกันในสายอื่นที่ห้อยใต้กล่อง (ดูคำขอผู้ใช้)
// ใช้ offsetHeight ซึ่งเป็นขนาด layout จริง ไม่ถูกกระทบจาก transform:scale ของการซูม
function equalizeSpacers(chartEl) {
  const spacers = chartEl.querySelectorAll(".oc-spacer");
  spacers.forEach((sp) => { sp.style.minHeight = ""; }); // reset ก่อนวัด (กันค่าค้างจากรอบก่อน)
  // ความสูงมาตรฐานทั้งผัง: กล่องหน่วยงาน (สูงเท่ากันหมดเพราะจองพื้นที่ไว้) และการ์ดพนักงาน — ใช้กับ
  // spacer ที่อยู่โดดเดี่ยวในแถว (แทน "ชั้นว่าง" ที่ไม่มีใครอยู่เลย เช่น ฝ่ายที่ไม่มีกล่องแผนกคั่น)
  let unitH = 0, empH = 0;
  chartEl.querySelectorAll(".oc-card").forEach((c) => {
    if (c.classList.contains("oc-unit")) unitH = Math.max(unitH, c.offsetHeight);
    else empH = Math.max(empH, c.offsetHeight);
  });
  spacers.forEach((sp) => {
    const li = sp.parentElement;          // <li> ที่ห่อ spacer
    const ul = li && li.parentElement;    // <ul> แถวพี่น้อง
    if (!ul) return;
    let maxCardH = 0;
    for (const sib of ul.children) {
      if (sib === li) continue;
      const card = sib.firstElementChild; // .oc-card หรือ .oc-spacer ของพี่น้อง
      if (card && card.classList.contains("oc-card")) maxCardH = Math.max(maxCardH, card.offsetHeight);
    }
    if (!maxCardH) {
      // ไม่มีกล่องพี่น้องในแถว (ชั้นว่างที่เติมจากบันไดตำแหน่ง) — ใช้ความสูงมาตรฐานตามชนิดชั้นที่แทน
      if (sp.classList.contains("oc-spacer--unit")) maxCardH = unitH;
      else if (sp.classList.contains("oc-spacer--emp")) maxCardH = empH;
    }
    if (maxCardH > 0) sp.style.minHeight = maxCardH + "px";
  });
}

// ย่อ/ขยายผังองค์กรให้พอดีพื้นที่แสดงผลเสมอ (คำนวณใหม่ทุกครั้งที่เนื้อหาเปลี่ยนขนาด เช่น เพิ่ม/ลด
// ตำแหน่งในผัง หรือเมื่อขนาดจอเปลี่ยน) โดยยังให้ผู้ใช้ zoom in/out เองเพิ่มเติมได้ผ่านปุ่ม
// scale สุดท้าย = fitScale (คำนวณอัตโนมัติ) x zoomFactor (ผู้ใช้ปรับเอง, ค้างไว้จนกว่าจะกด "พอดีจอ")
function useAutoFitZoom(dep) {
  const scrollRef = React.useRef(null); // กรอบพื้นที่แสดงผล (.orgchart-scroll)
  const chartRef = React.useRef(null);  // เนื้อผังขนาดจริง ไม่ถูกย่อ (.orgchart)
  const [fitScale, setFitScale] = React.useState(1);
  const [zoomFactor, setZoomFactor] = React.useState(1);
  const [contentSize, setContentSize] = React.useState({ w: 0, h: 0 });
  const anchorRef = React.useRef(null); // จุดยึด (fraction) ตอนซูมด้วยล้อเมาส์ ให้จุดใต้เคอร์เซอร์อยู่นิ่ง

  // recompute = "วัด" ขนาดผัง + คำนวณ fitScale เท่านั้น — ห้าม mutate DOM ในนี้เด็ดขาด
  // (เดิมเรียก equalizeSpacers ในนี้ ซึ่งไปแก้ความสูง spacer → ResizeObserver จับได้ → recompute ซ้ำ →
  //  แก้ DOM ซ้ำ วนไม่จบเฟรมต่อเฟรม = ภาพสั่นรัวๆ. ย้าย equalizeSpacers ไป effect [dep] ด้านล่างแทน)
  const recompute = React.useCallback(() => {
    const scrollEl = scrollRef.current, chartEl = chartRef.current;
    if (!scrollEl || !chartEl) return;
    const naturalW = chartEl.scrollWidth, naturalH = chartEl.scrollHeight;
    if (!naturalW || !naturalH) return;
    // อัปเดต state เฉพาะเมื่อค่าเปลี่ยนจริง — กัน re-render วนไม่จบ
    setContentSize((prev) => (prev.w === naturalW && prev.h === naturalH ? prev : { w: naturalW, h: naturalH }));
    // พอดีทั้งความกว้างและความสูง (เอาตัวที่ย่อมากกว่า) ไม่งั้นผังที่ลึกหลายชั้นจะกว้างพอดีจอแต่สูงเกินจอ
    const availW = scrollEl.clientWidth - 16; // เผื่อ padding ซ้าย-ขวา
    const availH = scrollEl.clientHeight - 16; // เผื่อ padding บน-ล่าง
    if (availW > 0 && availH > 0) {
      const next = Math.min(1, availW / naturalW, availH / naturalH);
      // กันสั่นเพิ่ม: อัปเดตเฉพาะเมื่อค่าต่างจากเดิม >1% (คู่กับ scrollbar-gutter:stable ใน CSS)
      setFitScale((prev) => (Math.abs(prev - next) < 0.01 ? prev : next));
    }
  }, []);

  // ปรับความสูง spacer (mutate DOM) เฉพาะตอนข้อมูลผัง (dep) เปลี่ยน — ครั้งเดียว ไม่วน
  React.useLayoutEffect(() => {
    if (chartRef.current) equalizeSpacers(chartRef.current);
    recompute();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [dep, recompute]);

  // ResizeObserver: วัดขนาดใหม่อย่างเดียว (ไม่แก้ DOM) → ไม่ retrigger ตัวเอง; coalesce ด้วย rAF
  React.useLayoutEffect(() => {
    let raf = 0;
    const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(recompute); };
    const ro = new ResizeObserver(schedule);
    if (scrollRef.current) ro.observe(scrollRef.current);
    if (chartRef.current) ro.observe(chartRef.current);
    window.addEventListener("resize", schedule);
    return () => { cancelAnimationFrame(raf); ro.disconnect(); window.removeEventListener("resize", schedule); };
  }, [recompute]);

  const scale = Math.max(0.1, Math.min(3, fitScale * zoomFactor));

  // ===== ซูมด้วยล้อเมาส์ + ลากด้วยคลิกซ้ายค้าง (แบบ Google Maps) =====
  React.useEffect(() => {
    const el = scrollRef.current;
    if (!el) return undefined;

    // -- ล้อเมาส์ = ซูมเข้า/ออก โดยยึดจุดใต้เคอร์เซอร์ให้อยู่นิ่ง --
    const onWheel = (e) => {
      e.preventDefault();
      const rect = el.getBoundingClientRect();
      const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
      const w = el.scrollWidth || 1, h = el.scrollHeight || 1;
      anchorRef.current = { fx: (el.scrollLeft + cx) / w, fy: (el.scrollTop + cy) / h, cx, cy };
      const dir = e.deltaY < 0 ? 1 : -1;
      setZoomFactor((z) => Math.max(0.3, Math.min(3, +(z + dir * 0.05).toFixed(3))));
    };
    el.addEventListener("wheel", onWheel, { passive: false });

    // -- คลิกซ้ายค้างแล้วลาก = แพน (เลื่อนดูไปมา) --
    let dragging = false, sx = 0, sy = 0, sl = 0, st = 0, moved = 0;
    const onDown = (e) => {
      if (e.button !== 0) return;
      dragging = true; moved = 0;
      sx = e.clientX; sy = e.clientY; sl = el.scrollLeft; st = el.scrollTop;
      el.classList.add("oc-grabbing");
    };
    const onMove = (e) => {
      if (!dragging) return;
      const dx = e.clientX - sx, dy = e.clientY - sy;
      moved = Math.max(moved, Math.abs(dx) + Math.abs(dy));
      el.scrollLeft = sl - dx; el.scrollTop = st - dy;
    };
    const onUp = () => {
      if (!dragging) return;
      dragging = false;
      el.classList.remove("oc-grabbing");
      // ถ้าลากจริง (ไม่ใช่คลิกเฉยๆ) ให้กันคลิกที่จะเด้งตามมา ไม่ให้เปิดการ์ดโดยไม่ตั้งใจ
      if (moved > 6) { el.__ocSuppressClick = true; setTimeout(() => { el.__ocSuppressClick = false; }, 0); }
    };
    const onClickCapture = (e) => {
      if (el.__ocSuppressClick) { e.stopPropagation(); e.preventDefault(); }
    };
    el.addEventListener("pointerdown", onDown);
    window.addEventListener("pointermove", onMove);
    window.addEventListener("pointerup", onUp);
    el.addEventListener("click", onClickCapture, true);

    return () => {
      el.removeEventListener("wheel", onWheel);
      el.removeEventListener("pointerdown", onDown);
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      el.removeEventListener("click", onClickCapture, true);
    };
    // ผูก listener ใหม่เมื่อเนื้อหาผัง (dep) พร้อม — เพราะ .orgchart-scroll ถูก mount หลังโหลดข้อมูลเสร็จ
    // (ถ้าใช้ [] จะผูกตอน el ยังเป็น null แล้วไม่ผูกอีกเลย)
  }, [dep]);

  // หลังซูม (scale เปลี่ยน) เลื่อน scroll ให้จุดยึดใต้เคอร์เซอร์อยู่ตำแหน่งเดิม
  React.useLayoutEffect(() => {
    const el = scrollRef.current, a = anchorRef.current;
    if (!el || !a) return;
    anchorRef.current = null;
    el.scrollLeft = a.fx * el.scrollWidth - a.cx;
    el.scrollTop = a.fy * el.scrollHeight - a.cy;
  }, [scale]);

  return {
    scrollRef, chartRef, scale, contentSize,
    zoomIn: () => setZoomFactor((z) => Math.min(3, +(z + 0.1).toFixed(2))),
    zoomOut: () => setZoomFactor((z) => Math.max(0.3, +(z - 0.1).toFixed(2))),
    resetZoom: () => setZoomFactor(1),
    canZoomIn: fitScale * zoomFactor < 3 - 0.001,
    canZoomOut: fitScale * zoomFactor > 0.1 + 0.001,
    isFit: Math.abs(zoomFactor - 1) < 0.001,
  };
}

// แถบปุ่ม zoom — ใช้ร่วมกันทั้งหน้าผังหลักและหน้าต่างดูรายละเอียดสายงาน
function ZoomControls({ zoom }) {
  return (
    <div className="oc-zoom-controls">
      <button type="button" className="link-btn oc-zoom-btn" onClick={zoom.zoomOut} disabled={!zoom.canZoomOut} title="ย่อ">－</button>
      <span className="oc-zoom-pct">{Math.round(zoom.scale * 100)}%</span>
      <button type="button" className="link-btn oc-zoom-btn" onClick={zoom.zoomIn} disabled={!zoom.canZoomIn} title="ขยาย">＋</button>
      <button type="button" className="link-btn oc-zoom-btn oc-zoom-btn--fit" onClick={zoom.resetZoom} disabled={zoom.isFit} title="พอดีหน้าจอ">⤢ พอดีจอ</button>
    </div>
  );
}

// สลับโหมดเต็มหน้าจอจริง (Fullscreen API) ให้ element ที่ระบุ — ใช้กับหน้าผังหลัก เพื่อซ่อนแถบเมนู/หัวเว็บ
// ตอนนำเสนอผังองค์กรแบบเต็มจอ (ต่างจาก .oc-modal--full ที่แค่ขยายเต็มพื้นที่ในหน้าเว็บเฉยๆ)
function useFullscreen(elRef) {
  const [isFullscreen, setIsFullscreen] = React.useState(false);
  React.useEffect(() => {
    function onChange() { setIsFullscreen(document.fullscreenElement === elRef.current); }
    document.addEventListener("fullscreenchange", onChange);
    return () => document.removeEventListener("fullscreenchange", onChange);
  }, [elRef]);
  const toggle = () => {
    if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
    else if (elRef.current && elRef.current.requestFullscreen) elRef.current.requestFullscreen().catch(() => {});
  };
  return { isFullscreen, toggle };
}

function OrgDiagramScreen({ onBack }) {
  const [structure, setStructure] = React.useState(null);
  const [employees, setEmployees] = React.useState([]);        // จาก org-tree (ใช้วาดผัง)
  const [fullEmployees, setFullEmployees] = React.useState([]); // จาก /api/employees (ข้อมูลครบ ใช้ในฟอร์มแก้ไข)
  const [loading, setLoading] = React.useState(true);
  // เก็บเป็น id (ไม่ใช่ตัว node) แล้ว derive node จาก roots — พอแก้ไขข้อมูลแล้วโหลดผังใหม่ modal จะรีเฟรชเอง
  const [detailLineId, setDetailLineId] = React.useState(null); // สายงานที่คลิกเปิดดูรายละเอียด
  const [groupViewId, setGroupViewId] = React.useState(null);   // กลุ่มสายงานที่คลิกเปิดดูแบบเต็มจอ
  const [editEmp, setEditEmp] = React.useState(null); // พนักงานที่คลิกเปิดฟอร์มแก้ไข (record เต็ม)

  async function loadAll() {
    const [s, t, eRes] = await Promise.all([
      window.MEI_API.orgStructure(),
      window.MEI_API.orgTree(),
      window.MEI_API.raw("/api/employees"),
    ]);
    const e = await eRes.json();
    setStructure(s);
    setEmployees((t && t.employees) || []);
    setFullEmployees(Array.isArray(e) ? e : []);
    setLoading(false);
  }
  React.useEffect(() => { loadAll(); }, []);

  const roots = React.useMemo(() => buildOrgChart(structure, employees, []), [structure, employees]);
  const detailLine = React.useMemo(() => detailLineId ? findNodeById(roots, detailLineId) : null, [roots, detailLineId]);
  const groupView = React.useMemo(() => groupViewId ? findNodeById(roots, groupViewId) : null, [roots, groupViewId]);
  const hasStructure = structure && (
    (structure.presidents || []).length > 0 || (structure.unassignedBusinessLines || []).length > 0
  );
  const zoom = useAutoFitZoom(roots);
  const pageRef = React.useRef(null);
  const fullscreen = useFullscreen(pageRef);

  function openEmp(empId) {
    const rec = fullEmployees.find((e) => e.id === empId);
    if (rec) setEditEmp(rec);
  }

  const empModal = editEmp && (
    <EditEmployeeForm
      person={editEmp}
      onClose={() => setEditEmp(null)}
      onDataChange={loadAll}
      user={window.MEI_USER}
    />
  );

  if (groupView) {
    return (
      <EmpClickContext.Provider value={openEmp}>
        <GroupDetailScreen group={groupView} onBack={() => setGroupViewId(null)} onLineClick={(n) => setDetailLineId(n.id)} />
        {detailLine && <LineDetailModal line={detailLine} onClose={() => setDetailLineId(null)} />}
        {empModal}
      </EmpClickContext.Provider>
    );
  }

  return (
    <EmpClickContext.Provider value={openEmp}>
    <div className="org-page org-page--fill" ref={pageRef}>
      {onBack && (
        <button className="link-btn" onClick={onBack} style={{ position: "absolute", top: 10, left: 32, zIndex: 6 }}>‹ กลับ</button>
      )}
      <h2 className="oc-float-title" title="แสดงถึงระดับสายงาน — คลิกที่กล่องสายงานเพื่อดูฝ่าย/แผนก/พนักงานภายใน (แก้โครงสร้างได้ที่หน้า &quot;จัดองค์กร&quot;)" style={onBack ? { left: 96 } : undefined}>ผังองค์กร (Organization Chart)</h2>
      {!loading && roots.length > 0 && (
        <div className="oc-float-zoom">
          <ZoomControls zoom={zoom} />
          <button type="button" className="link-btn oc-zoom-btn oc-zoom-btn--fit" onClick={fullscreen.toggle}
            title={fullscreen.isFullscreen ? "ออกจากเต็มจอ" : "ขยายเต็มจอ"}>
            {fullscreen.isFullscreen ? "⤡ ย่อ" : "⤢ เต็มจอ"}
          </button>
        </div>
      )}

      {loading ? (
        <p>กำลังโหลด...</p>
      ) : !hasStructure && roots.length === 0 ? (
        <p className="muted">ยังไม่มีโครงสร้างองค์กร — ไปที่หน้า "จัดองค์กร" เพื่อกำหนดสายงาน/ฝ่าย/แผนกก่อน</p>
      ) : (
        <div className="orgchart-scroll" ref={zoom.scrollRef}>
          <div className="orgchart-zoom" style={zoom.contentSize.w ? { width: zoom.contentSize.w * zoom.scale, height: zoom.contentSize.h * zoom.scale } : undefined}>
            <div className="orgchart" ref={zoom.chartRef} style={{ transform: `scale(${zoom.scale})` }}>
              <ul className="oc-root">
                {roots.map((root) => (
                  <OrgNode key={root.id} node={root} stopAtLine={true} onLineClick={(n) => setDetailLineId(n.id)} onGroupClick={(n) => setGroupViewId(n.id)} />
                ))}
              </ul>
            </div>
          </div>
        </div>
      )}

      {detailLine && <LineDetailModal line={detailLine} onClose={() => setDetailLineId(null)} />}
      {empModal}
    </div>
    </EmpClickContext.Provider>
  );
}

// หน้าจอเต็มจอแสดงสายงานทั้งหมดในกลุ่มสายงานที่คลิก (เปิดจากกล่อง "กลุ่มสายงาน" ในผังหลัก)
function GroupDetailScreen({ group, onBack, onLineClick }) {
  const zoom = useAutoFitZoom(group);
  const hasChildren = group.children && group.children.length > 0;
  return (
    <div className="org-page org-page--fill">
      <div className="oc-float-title oc-float-title--group">
        <button type="button" className="oc-back-btn" onClick={onBack} title="กลับไปผังหลัก">‹ กลับ</button>
        <span>กลุ่มสายงาน: {group.name}</span>
      </div>
      {hasChildren && <div className="oc-float-zoom"><ZoomControls zoom={zoom} /></div>}

      {!hasChildren ? (
        <p className="muted">กลุ่มนี้ยังไม่มีสายงาน</p>
      ) : (
        <div className="orgchart-scroll" ref={zoom.scrollRef}>
          <div className="orgchart-zoom" style={zoom.contentSize.w ? { width: zoom.contentSize.w * zoom.scale, height: zoom.contentSize.h * zoom.scale } : undefined}>
            <div className="orgchart" ref={zoom.chartRef} style={{ transform: `scale(${zoom.scale})` }}>
              <ul className="oc-root">
                {group.children.map((line) => (
                  <OrgNode key={line.id} node={line} stopAtLine={true} onLineClick={onLineClick} />
                ))}
              </ul>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

// ค้นหา node ตาม id ในทรีผัง (recursive) — ใช้ derive modal/หน้ากลุ่มจาก roots ที่ถูก rebuild ใหม่
// หลังแก้ไขข้อมูล เพื่อให้เนื้อหาใน modal อัปเดตตามอัตโนมัติ
function findNodeById(nodes, id) {
  for (const n of nodes || []) {
    if (n.id === id) return n;
    const found = findNodeById(n.children, id);
    if (found) return found;
  }
  return null;
}

// นับจำนวนพนักงานทั้งหมดในผังของ node หนึ่ง (รวมหัวหน้าที่ฝังในกล่องหน่วยงาน + การ์ดพนักงานเดี่ยว)
// ตามที่แสดงจริงในผังปัจจุบัน — ใช้แสดงบนเอกสาร PDF ที่ export ออกไป
function countPeople(node) {
  if (!node) return 0;
  let n = (node.headNames ? node.headNames.length : 0) + (node.kind === "emp" ? 1 : 0);
  (node.children || []).forEach((c) => { n += countPeople(c); });
  return n;
}

const mmToPx = (mm) => (mm / 25.4) * 96;

// หน้าต่างซ้อน (modal) แสดงฝ่าย/แผนก/พนักงานภายในสายงานที่คลิก
function LineDetailModal({ line, onClose }) {
  const [fullscreen, setFullscreen] = React.useState(false);
  const hasChildren = line.children && line.children.length > 0;
  const zoom = useAutoFitZoom(line);
  const empCount = React.useMemo(() => countPeople(line), [line]);

  // Export PDF ผ่าน window.print() — ไม่พึ่งไลบรารีภายนอก (ใช้ "บันทึกเป็น PDF" ของเบราว์เซอร์ได้เลย)
  // มี .oc-print-doc ซ่อนไว้นอกจอ พอสั่งพิมพ์ CSS @media print จะโชว์เฉพาะเอกสารนี้เป็น A4 แนวนอน
  const printChartRef = React.useRef(null);
  const [printBox, setPrintBox] = React.useState({ w: 0, h: 0, scale: 1 });
  const [printDate, setPrintDate] = React.useState("");
  function exportPDF() {
    setPrintDate(new Date().toLocaleString("th-TH", { dateStyle: "medium", timeStyle: "short" }));
    const chart = printChartRef.current;
    let box = { w: 0, h: 0, scale: 1 };
    if (chart) {
      equalizeSpacers(chart); // จัดระนาบ spacer ในเอกสารพิมพ์ให้ตรงกับที่แสดงบนจอ
      const w = chart.scrollWidth, h = chart.scrollHeight;
      // พื้นที่พิมพ์ A4 แนวนอน (padding 10mm รอบด้าน) เผื่อหัวเอกสาร ~24mm + ท้ายวันที่ ~10mm
      const availW = mmToPx(297 - 20), availH = mmToPx(210 - 20 - 24 - 10);
      const scale = (w && h) ? Math.min(1, availW / w, availH / h) : 1;
      box = { w, h, scale };
    }
    setPrintBox(box);
    // รอให้ React commit ขนาดที่ย่อแล้ว + เบราว์เซอร์ layout ก่อนเปิดหน้าต่างพิมพ์
    // (ใช้ setTimeout แทน requestAnimationFrame เพราะ RAF อาจถูก throttle ในบางบริบท)
    setTimeout(() => window.print(), 80);
  }

  return (
    <div className="oc-modal-backdrop" onClick={onClose}>
      <div className={"oc-modal" + (fullscreen ? " oc-modal--full" : "")} onClick={(e) => e.stopPropagation()}>
        <div className="oc-modal-head">
          <div>
            <span className="oc-utag" style={{ background: line.color }}>สายงาน</span>
            <strong className="oc-modal-title">{line.name}</strong>
            <span className="oc-modal-count">· {empCount} คน</span>
          </div>
          <div className="oc-modal-head-actions">
            {hasChildren && <ZoomControls zoom={zoom} />}
            <button className="link-btn" onClick={exportPDF} title="บันทึกผังเป็น PDF (A4 แนวนอน)">⬇ Export PDF</button>
            <button className="link-btn" onClick={() => setFullscreen(!fullscreen)}>
              {fullscreen ? "⤡ ย่อ" : "⤢ ขยายเต็มจอ"}
            </button>
            <button className="link-btn" onClick={onClose}>ปิด ✕</button>
          </div>
        </div>
        <div className="oc-modal-body">
          {!hasChildren ? (
            <p className="muted">สายงานนี้ยังไม่มีฝ่าย/แผนก — เพิ่มได้ที่หน้า "จัดองค์กร"</p>
          ) : (
            <div className="orgchart-scroll" ref={zoom.scrollRef}>
              <div className="orgchart-zoom" style={zoom.contentSize.w ? { width: zoom.contentSize.w * zoom.scale, height: zoom.contentSize.h * zoom.scale } : undefined}>
                <div className="orgchart" ref={zoom.chartRef} style={{ transform: `scale(${zoom.scale})` }}>
                  <ul className="oc-root">
                    <OrgNode node={line} stopAtLine={false} />
                  </ul>
                </div>
              </div>
            </div>
          )}
        </div>
      </div>

      {/* เอกสารสำหรับพิมพ์ — ซ่อนนอกจอตอนปกติ โชว์เฉพาะตอนสั่งพิมพ์ (ดู @media print ใน styles.css) */}
      <div className="oc-print-doc" onClick={(e) => e.stopPropagation()}>
        <div className="oc-print-header">
          <div className="oc-print-title">
            <span className="oc-utag" style={{ background: line.color }}>สายงาน</span>
            <h1>{line.name}</h1>
          </div>
          <div className="oc-print-meta">จำนวนพนักงานตามผังปัจจุบัน: <strong>{empCount}</strong> คน</div>
        </div>
        <div className="oc-print-chart" style={printBox.w ? { width: printBox.w * printBox.scale, height: printBox.h * printBox.scale } : undefined}>
          <div className="orgchart" ref={printChartRef} style={{ transform: `scale(${printBox.scale})`, transformOrigin: "top left" }}>
            <ul className="oc-root">
              <OrgNode node={line} stopAtLine={false} />
            </ul>
          </div>
        </div>
        <div className="oc-print-footer">พิมพ์เมื่อ {printDate}</div>
      </div>
    </div>
  );
}

function OrgNode({ node, stopAtLine, onLineClick, onGroupClick }) {
  const isLine = node.unitType === "line";
  const isLineGroup = node.unitType === "linegroup";
  const truncate = stopAtLine && (isLine || isLineGroup); // โหมดผังหลัก: หยุดที่สายงาน/กลุ่มสายงาน ไม่แสดงลูก
  const handleClick = isLineGroup ? () => onGroupClick(node) : () => onLineClick(node);
  return (
    <li>
      {node.kind === "emp"
        ? <EmpBox emp={node.emp} color={node.color} />
        : node.kind === "spacer"
        ? <div className={"oc-spacer" + (node.standIn === "unit" ? " oc-spacer--unit" : node.standIn === "emp" ? " oc-spacer--emp" : "")} />
        : <UnitBox node={node} clickable={truncate} onClick={truncate ? handleClick : undefined} />}
      {!truncate && node.children.length > 0 && (
        <ul>
          {node.children.map((c) => <OrgNode key={c.id} node={c} stopAtLine={stopAtLine} onLineClick={onLineClick} onGroupClick={onGroupClick} />)}
        </ul>
      )}
    </li>
  );
}

const UNIT_HEAD_LABEL = {
  president: "ประธานบริหาร", director: "ผู้อำนวยการ", line: "รองผู้อำนวยการ",
  division: "ผู้จัดการฝ่าย", department: "ผู้จัดการแผนก",
};

function UnitBox({ node, clickable, onClick }) {
  const onEmpClick = React.useContext(EmpClickContext);
  const isLineGroup = node.unitType === "linegroup";
  const tag = node.unitType === "president" ? "ประธานบริหาร"
    : node.unitType === "director" ? "ผู้อำนวยการ"
    : node.unitType === "line" ? "สายงาน"
    : isLineGroup ? "กลุ่มสายงาน"
    : node.unitType === "division" ? "ฝ่าย"
    : node.unitType === "department" ? "แผนก" : "";
  const headLabel = UNIT_HEAD_LABEL[node.unitType];
  const headNames = node.headNames || [];
  // ชื่อหน่วยงาน (uname) กับป้าย (tag) ซ้ำกันได้ในระดับประธานบริหาร (มีตำแหน่งเดียว ชื่อหน่วยงาน
  // เลยเป็นคำเดียวกับ tag เป๊ะ) — ไม่ต้องแสดง uname ซ้ำอีกรอบ. เช่นเดียวกับตำแหน่งหัวหน้าที่โชว์ใต้
  // ชื่อคน (headLabel) ถ้าตรงกับ tag อยู่แล้ว (ประธานบริหาร/ผู้อำนวยการ) ก็ไม่ต้องซ้ำอีกที
  const showUname = node.name && node.name !== tag;
  const showHeadLabel = headLabel && headLabel !== tag;
  return (
    <div className={"oc-card oc-unit" + (clickable ? " oc-clickable" : "")}
      style={{ borderTopColor: node.color }}
      onClick={onClick}
      title={clickable ? (isLineGroup ? "คลิกเพื่อดูสายงานในกลุ่ม" : "คลิกเพื่อดูฝ่าย/แผนก") : undefined}>
      {tag && <span className="oc-utag" style={{ background: node.color }}>{tag}</span>}
      {showUname && <span className="oc-uname">{node.name}</span>}
      {/* จองพื้นที่ไว้เสมอถ้าระดับนี้มีแนวคิด "หัวหน้า" ได้ (แม้ยังไม่มีคนตำแหน่งนี้จริง) เพื่อให้กล่อง
          พี่น้องกัน (เช่น ผู้อำนวยการสองกล่อง กล่องหนึ่งมีหัวหน้า อีกกล่องยังไม่มี) สูงเท่ากันเสมอ
          ไม่งั้นลูกที่อยู่ใต้กล่องที่สั้นกว่าจะเยื้องขึ้นสูงกว่าลูกของกล่องที่ยาวกว่า */}
      {headLabel && (
        <div className="oc-uhead-list">
          {headNames.map((head, i) => {
            const headClickable = !!onEmpClick && head.id != null;
            return (
            <div className={"oc-uhead-item" + (headClickable ? " oc-emp-clickable" : "")} key={i}
              onClick={headClickable ? (e) => { e.stopPropagation(); onEmpClick(head.id); } : undefined}
              title={headClickable ? "คลิกเพื่อดู/แก้ไขข้อมูลพนักงาน" : undefined}>
              <Avatar photoData={head.photo_data} initial={ocDisplayName(head.full_name).charAt(0) || "?"}
                color={node.color} size="lg" />
              {/* ชื่อตำแหน่งขึ้นก่อน (ตัวใหญ่/เด่น) แล้วชื่อคนอยู่ล่าง — รูปแบบเดียวกับการ์ดพนักงาน */}
              {showHeadLabel && <div className="oc-pos oc-uhead-pos" style={{ color: node.color }}>{headLabel}</div>}
              <div className="oc-name oc-uhead-name">{ocDisplayName(head.full_name)}</div>
            </div>
            );
          })}
        </div>
      )}
      {isLineGroup && <div className="oc-group-count">{(node.children || []).length} สายงาน</div>}
      {clickable && <span className="oc-expand" style={{ color: node.color }}>See more ›</span>}
    </div>
  );
}

function EmpBox({ emp, color }) {
  const onEmpClick = React.useContext(EmpClickContext);
  const clickable = !!onEmpClick && emp.id != null;
  return (
    <div className={"oc-card" + (clickable ? " oc-emp-clickable" : "")}
      onClick={clickable ? () => onEmpClick(emp.id) : undefined}
      title={clickable ? "คลิกเพื่อดู/แก้ไขข้อมูลพนักงาน" : undefined}>
      <Avatar photoData={emp.photo_data} initial={ocInitial(emp)} color={color} />
      <div className="oc-info">
        {/* สลับ: ชื่อตำแหน่งขึ้นก่อน (ตัวใหญ่/เด่น) แล้วชื่อคนอยู่ล่าง (ตัวรอง) */}
        <div className="oc-pos oc-pos--lead" style={{ color }}>{emp.position || "—"}</div>
        <div className="oc-name oc-name--sub">{ocDisplayName(emp.full_name)}</div>
      </div>
    </div>
  );
}
