// ---- 83F 2026 Plan Data ----
// brand / target / baseline ย้ายไปที่ config.js (window.TENANT) แล้ว — แก้ค่าได้ที่ไฟล์เดียว
const BRAND = window.TENANT.brand;
const TARGET = window.TENANT.target;
const BASELINE = window.TENANT.baseline;

const PILLARS = [
  {
    key: "R",
    cls: "r",
    name: "Recruiting",
    th: "สรรหา",
    desc: `ขยายฐานตัวแทนใหม่ให้ครบ ${TARGET.agents.toLocaleString("en-US")} manpower ผ่าน Open House, CSR, Connect Club`,
    full: "สรรหา · Recruiting คือเสาแรกของ R.T.M.S — ขับเคลื่อนการเติบโตของสำนักงาน 83F ด้วยช่องทางสรรหาที่หลากหลาย ครอบคลุมตั้งแต่ผู้สนใจอาชีพไปจนถึงเครือข่ายในชุมชน",
    metric: { label: "ตัวแทนใหม่/เดือน", value: `+${TARGET.agents.toLocaleString("en-US")} manpower รวม` },
  },
  {
    key: "T",
    cls: "t",
    name: "Training",
    th: "พัฒนา",
    desc: "ระบบคลับเรียนรู้ 9 คลับครอบคลุมทุกระดับ ตั้งแต่ First Stage ถึง Master Owner",
    full: "พัฒนา · Training คือเครื่องยนต์ของยอดขาย — ทักษะการขายพื้นฐาน, Mindset และการพัฒนาผู้นำ ถูกฝังในระบบคลับ 6 วัน/สัปดาห์",
    metric: { label: "คลับการเรียน", value: "9 คลับ · 6 วัน/สัปดาห์" },
  },
  {
    key: "M",
    cls: "m",
    name: "Motivating",
    th: "สร้างแรงจูงใจ",
    desc: "Morning Mindset, Contest รายไตรมาส, Recognition และ Trip กระตุ้นใจตลอดปี",
    full: `สร้างแรงจูงใจ · Motivating คือเชื้อเพลิง — ใช้ Contest 4 ไตรมาส, Recognition รายเดือน และ Trip ปลายปี เพื่อรักษา momentum ของทีม ${TARGET.agents.toLocaleString("en-US")} คน`,
    metric: { label: "กิจกรรมจูงใจ/ปี", value: "12+ ครั้ง" },
  },
  {
    key: "S",
    cls: "s",
    name: "Supervising",
    th: "ติดตาม",
    desc: "Daily Tracking · Weekly One-on-One · Monthly Business Review ทุก Unit",
    full: "ติดตาม · Supervising คือพวงมาลัย — ผู้บริหารเห็นข้อมูลจริงตั้งแต่รายวันถึงรายไตรมาส ปรับเกมได้ทันเวลา",
    metric: { label: "วงจรติดตาม", value: "Daily · Weekly · Monthly" },
  },
];

const CLUBS = [];

const ACTIVITIES = [
  {
    id: "open-house",
    name: "Open House",
    pillar: "r",
    tag: "R",
    freq: "เดือนละ 2 ครั้ง",
    timesPerYear: 24,
    venue: "สำนักงาน 83F",
    desc: "สรรหาตัวแทนใหม่ — เปิดสำนักงานต้อนรับผู้สนใจ พร้อม session แชร์อาชีพและโอกาส",
    kpi: "ผู้สนใจ ≥ 12 คน/ครั้ง · Convert 25% → 3 คน/ครั้ง",
  },
  {
    id: "csr-phuket",
    name: "CSR ภูเก็ต",
    pillar: "rm",
    tag: "R",
    freq: "ไตรมาสละ 1 ครั้ง",
    timesPerYear: 4,
    venue: "ชุมชนในภูเก็ต",
    desc: "กิจกรรมเพื่อสังคม — สร้างแบรนด์ 83F และเครือข่ายผู้นำชุมชน เปิดประตูสรรหาและขาย",
    kpi: "ผู้เข้าร่วม ≥ 100 คน · Lead ใหม่ ≥ 30 คน/ครั้ง",
  },
  {
    id: "connect-meet",
    name: "Connect Club Networking",
    pillar: "t",
    tag: "T",
    freq: "เดือนละ 2 ครั้ง",
    timesPerYear: 24,
    venue: "สำนักงาน 83F",
    desc: "พบปะ networking ตัวแทน + พันธมิตร — รักษาแรงและสร้างโอกาสใหม่ในพื้นที่",
    kpi: "Lead ใหม่ ≥ 5 คน/ครั้ง · Renewal contact 100%",
  },
];

const LMS_LESSONS = [];

const CALENDAR_EVENTS = [
  {
    id: "open-house-2026-06-06",
    name: "Open House",
    date: "2026-06-06",
    time: "14:00–16:00",
    pillar: "r",
    desc: "กิจกรรม Open House ที่สำนักงาน 83F",
  },
  {
    id: "open-house-2026-07-04",
    name: "Open House",
    date: "2026-07-04",
    time: "14:00–16:00",
    pillar: "r",
    desc: "กิจกรรม Open House ที่สำนักงาน 83F",
  },
  {
    id: "open-house-2026-07-18",
    name: "Open House",
    date: "2026-07-18",
    time: "14:00–16:00",
    pillar: "r",
    desc: "กิจกรรม Open House ที่สำนักงาน 83F",
  },
  {
    id: "open-house-2026-08-08",
    name: "Open House",
    date: "2026-08-08",
    time: "14:00–16:00",
    pillar: "r",
    desc: "กิจกรรม Open House ที่สำนักงาน 83F",
  },
  {
    id: "open-house-2026-08-22",
    name: "Open House",
    date: "2026-08-22",
    time: "14:00–16:00",
    pillar: "r",
    desc: "กิจกรรม Open House ที่สำนักงาน 83F",
  },
  {
    id: "open-house-2026-09-05",
    name: "Open House",
    date: "2026-09-05",
    time: "14:00–16:00",
    pillar: "r",
    desc: "กิจกรรม Open House ที่สำนักงาน 83F",
  },
  {
    id: "open-house-2026-09-19",
    name: "Open House",
    date: "2026-09-19",
    time: "14:00–16:00",
    pillar: "r",
    desc: "กิจกรรม Open House ที่สำนักงาน 83F",
  },
  {
    id: "open-house-2026-10-03",
    name: "Open House",
    date: "2026-10-03",
    time: "14:00–16:00",
    pillar: "r",
    desc: "กิจกรรม Open House ที่สำนักงาน 83F",
  },
  {
    id: "open-house-2026-10-17",
    name: "Open House",
    date: "2026-10-17",
    time: "14:00–16:00",
    pillar: "r",
    desc: "กิจกรรม Open House ที่สำนักงาน 83F",
  },
  {
    id: "open-house-2026-11-07",
    name: "Open House",
    date: "2026-11-07",
    time: "14:00–16:00",
    pillar: "r",
    desc: "กิจกรรม Open House ที่สำนักงาน 83F",
  },
  {
    id: "open-house-2026-11-21",
    name: "Open House",
    date: "2026-11-21",
    time: "14:00–16:00",
    pillar: "r",
    desc: "กิจกรรม Open House ที่สำนักงาน 83F",
  },
  {
    id: "open-house-2026-12-12",
    name: "Open House",
    date: "2026-12-12",
    time: "14:00–16:00",
    pillar: "r",
    desc: "กิจกรรม Open House ที่สำนักงาน 83F",
  },
];

// Plan runs 1 Jun → 31 Dec 2569 (7 months)
const ZOOM_URL = "https://us06web.zoom.us/j/89233415362";
const MONTHS = ["พ.ค.", "มิ.ย.", "ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."];
const KICKOFF = "1 มิ.ย. 2569";
const CLOSE = "31 ธ.ค. 2569";
// วันเริ่มฤดูกาลติดตาม APE แบบ ISO (ตรงกับ KICKOFF ด้านบน/activeFrom ของตารางคลับ) —
// ใช้กรอง query ฝั่ง public landing ไม่ให้ดึงข้อมูลก่อนฤดูกาลนี้ (BASELINE นับรวมไปแล้ว)
const SEASON_START = "2026-06-01";

// APE monthly progress — update `achieved` each month
const APE_MONTHLY_PROGRESS = [
  { month: "มิ.ย.", focus: "Kick-off",  target: 3000000, achieved: 0 },
  { month: "ก.ค.", focus: "Recruit",   target: 3000000, achieved: 0 },
  { month: "ส.ค.", focus: "Push",      target: 3000000, achieved: 0 },
  { month: "ก.ย.", focus: "Review",    target: 3000000, achieved: 0 },
  { month: "ต.ค.", focus: "Comeback",  target: 3000000, achieved: 0 },
  { month: "พ.ย.", focus: "Pipeline",  target: 3000000, achieved: 0 },
  { month: "ธ.ค.", focus: "Final",     target: 3000000, achieved: 0 },
];

const APE_RATE = {
  daily:   136364,   // 3,000,000 ÷ 22 วันทำการ
  weekly:  750000,   // 3,000,000 ÷ 4 สัปดาห์
  monthly: 3000000,
};

const NavIcon = ({ children }) => (
  <svg className="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
       strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
    {children}
  </svg>
);

const NAV_ICONS = {
  overview:     <NavIcon><path d="M3 12 12 4l9 8" /><path d="M5 10v10h14V10" /></NavIcon>,
  apeGoal:      <NavIcon><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="5" /><circle cx="12" cy="12" r="1.5" /></NavIcon>,
  training:     <NavIcon><path d="M4 6.5A2.5 2.5 0 0 1 6.5 4H20v15H6.5A2.5 2.5 0 0 0 4 21.5v-15Z" /><path d="M4 6.5A2.5 2.5 0 0 0 6.5 9H20" /></NavIcon>,
  login:        <NavIcon><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><path d="m10 17 5-5-5-5" /><path d="M15 12H3" /></NavIcon>,
  ape:          <NavIcon><path d="M12 20V10" /><path d="m6 14 6-6 6 6" /><path d="M5 4h14" /></NavIcon>,
  lms:          <NavIcon><path d="M3 6h18" /><path d="M3 12h18" /><path d="M3 18h12" /></NavIcon>,
  schedule:     <NavIcon><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M16 3v4M8 3v4M3 11h18" /></NavIcon>,
  activities:   <NavIcon><path d="m12 3 2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.4 6.8 19.1l1-5.8L3.5 9.2l5.9-.9L12 3Z" /></NavIcon>,
  history:      <NavIcon><path d="M3 12a9 9 0 1 0 3-6.7L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l3 2" /></NavIcon>,
  leaderboard:  <NavIcon><path d="M4 20h4V10H4z" /><path d="M10 20h4V4h-4z" /><path d="M16 20h4v-8h-4z" /></NavIcon>,
  rewards:      <NavIcon><rect x="3" y="8" width="18" height="12" rx="1" /><path d="M3 12h18" /><path d="M12 8v12" /><path d="M7.5 8a2.5 2.5 0 0 1 0-5c2 0 4.5 2 4.5 5" /><path d="M16.5 8a2.5 2.5 0 0 0 0-5c-2 0-4.5 2-4.5 5" /></NavIcon>,
  goals:        <NavIcon><path d="M4 4v16" /><path d="M4 5h13l-2 4 2 4H4" /></NavIcon>,
  profile:      <NavIcon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></NavIcon>,
  members:      <NavIcon><circle cx="9" cy="8" r="3.5" /><path d="M2.5 20a6.5 6.5 0 0 1 13 0" /><circle cx="17" cy="9" r="2.5" /><path d="M16 20a5 5 0 0 1 5.5-5" /></NavIcon>,
  finance:      <NavIcon><rect x="3" y="6" width="18" height="13" rx="2" /><circle cx="12" cy="12.5" r="2.5" /><path d="M7 11v3M17 11v3" /></NavIcon>,
  redemptions:  <NavIcon><path d="M4 7h16l-1.5 12.5a2 2 0 0 1-2 1.5H7.5a2 2 0 0 1-2-1.5L4 7Z" /><path d="M8 7V5a4 4 0 0 1 8 0v2" /><path d="m9 12 2 2 4-4" /></NavIcon>,
  home:         <NavIcon><path d="M3 11.5 12 4l9 7.5" /><path d="M5 10v10h14V10" /><path d="M10 20v-5h4v5" /></NavIcon>,
  logout:       <NavIcon><path d="M9 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4" /><path d="m15 17 5-5-5-5" /><path d="M20 12H9" /></NavIcon>,
  challenges:   <NavIcon><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></NavIcon>,
  events:       <NavIcon><path d="M5 3v18"/><path d="M5 5h12l-3 5 3 5H5"/></NavIcon>,
  customers:    <NavIcon><rect x="3" y="7" width="18" height="13" rx="2" /><path d="M8 7V5a4 4 0 0 1 8 0v2" /><path d="M3 13h18" /></NavIcon>,
  announce:     <NavIcon><path d="M3 11v2a1 1 0 0 0 1 1h2l5 4V6L6 10H4a1 1 0 0 0-1 1Z" /><path d="M15 8a4 4 0 0 1 0 8" /><path d="M18 5a8 8 0 0 1 0 14" /></NavIcon>,
  settings:     <NavIcon><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></NavIcon>,
  courseReplay: <NavIcon><rect x="2" y="4" width="20" height="14" rx="2"/><path d="m10 9 5 3-5 3z"/><path d="M8 21h8"/></NavIcon>,
  medal:        <NavIcon><circle cx="12" cy="8" r="5" /><path d="M8.6 12.4 7 21l5-2.8L17 21l-1.6-8.6" /></NavIcon>,
  trophy:       <NavIcon><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" /><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" /><path d="M4 22h16" /><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" /><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" /><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" /></NavIcon>,
  capacity:     <NavIcon><ellipse cx="12" cy="5" rx="8" ry="3" /><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5" /><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" /></NavIcon>,
};

const NAV = [
  { id: "login",             label: "Login",              num: "01", icon: NAV_ICONS.login, href: "login.html" },
];

// Dashboard menu items shown after login. ฐานเป็นไทย — ระบบ i18n จะแปลเป็น EN อัตโนมัติ
// (คำอังกฤษเช่น Prospecting/Recruiting คงเดิมทั้งสองภาษาตามต้องการ)
const AGENT_NAV = [
  { id: "sales",        label: "Prospecting",           num: "01", icon: NAV_ICONS.customers },
  { id: "recruit",      label: "Recruiting",            num: "02", icon: NAV_ICONS.customers },
  { id: "overview",     label: "สรุปให้แล้ว",           num: "03", icon: NAV_ICONS.overview },
  { id: "lms",          label: "อัปสกิล LMS",           num: "04", icon: NAV_ICONS.lms },
  { id: "course-replay",label: "เรียนย้อนหลัง",          num: "05", icon: NAV_ICONS.courseReplay },
  { id: "schedule",     label: "ตารางเรียน",            num: "06", icon: NAV_ICONS.schedule },
  { id: "events",       label: "กิจกรรม",               num: "07", icon: NAV_ICONS.events },
  { id: "activities",   label: "แต้มกิจกรรม",           num: "08", icon: NAV_ICONS.activities },
  { id: "leaderboard",  label: "ตัวตึง",                num: "09", icon: NAV_ICONS.leaderboard },
  { id: "rewards",      label: "แลกของ",                num: "10", icon: NAV_ICONS.rewards },
  { id: "challenges",   label: "Daily challenges",       num: "11", icon: NAV_ICONS.challenges },
  { id: "goals",        label: "My Money Need",          num: "12", icon: NAV_ICONS.goals },
  { id: "goal-track",   label: "เป้าหมาย",              num: "13", icon: NAV_ICONS.apeGoal },
  { id: "profile",      label: "โปรไฟล์",               num: "14", icon: NAV_ICONS.profile },
  { id: "onboarding",   label: "แผน 90 วัน",            num: "15", icon: NAV_ICONS.goals },
  { id: "team-competition", label: "ทีม VS ทีม",        num: "16", icon: NAV_ICONS.trophy, addedAt: "2026-07-10" },
  { id: "qualification", label: "คุณวุฒิ",              num: "17", icon: NAV_ICONS.medal, addedAt: "2026-07-31" },
];

// ---- Sales Tracking (ติดตามการขาย) — ค่าคงที่ใช้ร่วมทั้งฝั่งตัวแทนและแอดมิน ----
const SALES_STAGES = [
  { key: "prospect",     label: "หารายชื่อ", short: "รายชื่อ", cls: "prospect" },
  { key: "appointment",  label: "ทำนัด",     short: "นัด",     cls: "appointment" },
  { key: "meeting",      label: "เข้าพบ",     short: "เข้าพบ",  cls: "meeting" },
  { key: "presentation", label: "นำเสนอ",    short: "นำเสนอ",  cls: "presentation" },
  { key: "closed_won",   label: "ปิดได้",     short: "ปิดได้",  cls: "won" },
  { key: "closed_lost",  label: "ปิดไม่ได้",  short: "หลุด",    cls: "lost" },
];
const SALES_STAGE_MAP   = Object.fromEntries(SALES_STAGES.map(s => [s.key, s]));
const SALES_OPEN_STAGES = ["prospect", "appointment", "meeting", "presentation"];
const salesStageLabel   = (key) => SALES_STAGE_MAP[key]?.label || key;
const SALES_LOST_REASONS   = ["ราคา/งบไม่พอ", "ไม่สนใจ", "ติดต่อไม่ได้", "มีกรมธรรม์แล้ว", "อื่นๆ"];
const SALES_VISIT_OUTCOMES = ["สนใจ", "ขอคิดดู", "ปฏิเสธ", "ปิดได้"];
const SALES_GENDERS        = ["ชาย", "หญิง", "อื่นๆ"];
const SALES_INCOME_RANGES  = ["ต่ำกว่า 15,000", "15,000–30,000", "30,000–50,000", "50,000–100,000", "มากกว่า 100,000"];
const SALES_INTERESTS       = ["ประกันชีวิต", "ประกันสุขภาพ", "ประกันโรคร้ายแรง", "ประกันออมทรัพย์", "ประกัน unitlink", "ประกันบำนาญ"];
const SALES_HEALTH_LEVELS   = ["แข็งแรงดีมาก", "แข็งแรงดี", "ปานกลาง", "มีโรคประจำตัว", "มีประวัติรักษา"];
const SALES_RESCHEDULE_REASONS = ["ลูกค้าติดธุระ", "ลูกค้าขอเลื่อน", "ตัวแทนติดงาน", "ติดต่อลูกค้าไม่ได้", "อื่นๆ"];
const salesVisitUrl = (path) =>
  path ? `${window.SUPABASE_URL}/storage/v1/object/public/sales-visits/${path}` : "";

/* @onboarding-logic-start */
// สถานะข้อย่อย/หมุด ของแผนออนบอร์ด 90 วัน — ฟังก์ชันบริสุทธิ์ แชร์สองฝั่ง (ทดสอบใน tests/onboarding-logic.test.mjs)
const OB_STATE = { IN_PROGRESS: "in_progress", PENDING: "pending_approval", APPROVED: "approved" };

// ข้อเสร็จหรือยัง: manual ดู marksSet, auto เทียบ target กับ ctx {lmsCount, manhaCount, dealWonCount}
function obItemDone(item, marksSet, ctx) {
  if (!item.auto_source) return marksSet.has(item.id);
  const target = item.auto_target || 1;
  if (item.auto_source === "lms") return (ctx.lmsCount || 0) >= target;
  if (item.auto_source === "manha") return (ctx.manhaCount || 0) >= target;
  if (item.auto_source === "deal_won") return (ctx.dealWonCount || 0) >= target;
  return false;
}

// สถานะหมุด: {state, overdue, doneCount, total}
function obMilestoneStatus(ms, allItems, marksSet, approvalsSet, ctx, startedAt, now) {
  const items = allItems.filter(function (it) { return it.milestone_id === ms.id && it.active !== false; });
  const doneCount = items.filter(function (it) { return obItemDone(it, marksSet, ctx); }).length;
  const total = items.length;
  const approved = approvalsSet.has(ms.id);
  let state = OB_STATE.IN_PROGRESS;
  if (approved) state = OB_STATE.APPROVED;
  else if (total > 0 && doneCount === total) state = OB_STATE.PENDING;
  let overdue = false;
  if (!approved && startedAt && now) {
    const due = new Date(startedAt).getTime() + ms.day * 86400000;
    overdue = new Date(now).getTime() > due;
  }
  return { state: state, overdue: overdue, doneCount: doneCount, total: total };
}

// ความคืบหน้ารวม: {done, total, pct}
function obProgress(allItems, marksSet, ctx) {
  const items = allItems.filter(function (it) { return it.active !== false; });
  const done = items.filter(function (it) { return obItemDone(it, marksSet, ctx); }).length;
  const total = items.length;
  const pct = total ? Math.round((done / total) * 100) : 0;
  return { done: done, total: total, pct: pct };
}

// วันที่ X ของแผน (เริ่มนับ 1) — null ถ้ายังไม่เริ่ม
function obDayNumber(startedAt, now) {
  if (!startedAt) return null;
  const ms = new Date(now).getTime() - new Date(startedAt).getTime();
  return Math.floor(ms / 86400000) + 1;
}

// สถานะจุดบนเส้นทางเดินของ "เฟส" ในหน้าแผน 90 วัน (การ์ดตัวแทน) — ฟังก์ชันบริสุทธิ์ ทดสอบใน tests/onboarding-logic.test.mjs
// currentAlreadyAssigned: true ถ้ามีเฟสก่อนหน้าที่ถูกจัดเป็น "current" ไปแล้ว (ไล่ทีละเฟสจากผู้เรียก)
function obPhaseRailState(milestoneStatuses, currentAlreadyAssigned) {
  // "done" มาก่อน "upcoming" เสมอ แม้เฟสนี้จะอยู่หลังเฟส current — เจตนา: สะท้อนความคืบหน้าจริง ไม่ปิดบังเฟสที่เสร็จแล้ว
  const allApproved = milestoneStatuses.length > 0 &&
    milestoneStatuses.every(function (s) { return s.state === OB_STATE.APPROVED; });
  if (allApproved) {
    return { state: "done", overdue: false, currentAssigned: currentAlreadyAssigned };
  }
  if (!currentAlreadyAssigned) {
    const anyOverdue = milestoneStatuses.some(function (s) { return s.overdue; });
    return { state: "current", overdue: anyOverdue, currentAssigned: true };
  }
  return { state: "upcoming", overdue: false, currentAssigned: currentAlreadyAssigned };
}
/* @onboarding-logic-end */

// ---- Recruiting (สรรหาตัวแทน) — pipeline คู่ขนานกับ Prospecting ----
const RECRUIT_STAGES = [
  { key: "prospect",  label: "รายชื่อ",      short: "รายชื่อ",  cls: "prospect" },
  { key: "bop",       label: "นัด BOP",      short: "BOP",     cls: "bop" },
  { key: "interview", label: "สัมภาษณ์",     short: "สัมภาษณ์", cls: "interview" },
  { key: "exam",      label: "สอบ/ใบอนุญาต", short: "สอบ",     cls: "exam" },
  { key: "recruited", label: "สอบผ่าน",      short: "สอบผ่าน", cls: "won" },
  { key: "dropped",   label: "สอบไม่ผ่าน",   short: "ไม่ผ่าน", cls: "lost" },
];
const RECRUIT_STAGE_MAP   = Object.fromEntries(RECRUIT_STAGES.map(s => [s.key, s]));
const RECRUIT_OPEN_STAGES = ["prospect", "bop", "interview", "exam"];
const recruitStageLabel   = (key) => RECRUIT_STAGE_MAP[key]?.label || key;
// stage ที่เป็น "นัดหมาย" (มีวันนัด + เลื่อนนัดได้)
const RECRUIT_APPT_STAGES = ["bop", "interview"];
const RECRUIT_SOURCES      = ["คนรู้จัก/เพื่อน", "ลูกค้าเดิม", "คนแนะนำ", "โซเชียล", "อีเวนต์/บูธ", "อื่นๆ"];
const RECRUIT_DROP_REASONS = ["สอบตก", "ไม่ไปสอบ", "ขาดคุณสมบัติ", "ถอนตัว", "ติดต่อไม่ได้", "อื่นๆ"];
const RECRUIT_RESCHEDULE_REASONS = ["ผู้สมัครติดธุระ", "ผู้สมัครขอเลื่อน", "ตัวแทนติดงาน", "ติดต่อไม่ได้", "อื่นๆ"];
const recruitDocUrl = (path) =>
  path ? `${window.SUPABASE_URL}/storage/v1/object/public/sales-visits/${path}` : "";

// Wish categories for the agent "เป้าหมายของฉัน" tab — each has its own
// follow-up questions. Shared by the agent dashboard and admin panel.
const WISH_TYPES = [
  { id: "house", label: "บ้าน", icon: "🏠", fields: [
    { key: "price", label: "ราคาบ้าน", type: "money", unit: "บาท" },
    { key: "down", label: "เงินดาวน์", type: "money", unit: "บาท" },
    { key: "location", label: "ทำเล / ที่ตั้ง", type: "text", placeholder: "เช่น ใกล้รถไฟฟ้าสายสีเขียว" },
  ] },
  { id: "car", label: "รถยนต์", icon: "🚗", fields: [
    { key: "model", label: "ยี่ห้อ / รุ่น", type: "text", placeholder: "เช่น Toyota Camry" },
    { key: "price", label: "ราคา", type: "money", unit: "บาท" },
  ] },
  { id: "trip", label: "ทริปท่องเที่ยว", icon: "✈️", fields: [
    { key: "destination", label: "จุดหมายปลายทาง", type: "text", placeholder: "เช่น ญี่ปุ่น" },
    { key: "budget", label: "งบประมาณ", type: "money", unit: "บาท" },
    { key: "when", label: "ช่วงเวลาที่อยากไป", type: "text", placeholder: "เช่น ปลายปี 2026" },
  ] },
  { id: "cash", label: "เงินสด", icon: "💵", fields: [
    { key: "amount", label: "จำนวนเงินที่อยากมี", type: "money", unit: "บาท" },
  ] },
  { id: "other", label: "อื่นๆ", icon: "✨", fields: [
    { key: "name", label: "สิ่งที่ต้องการ", type: "text", placeholder: "ระบุสิ่งที่ต้องการ" },
    { key: "price", label: "ราคา / งบประมาณ", type: "money", unit: "บาท" },
  ] },
];
const wishTypeOf = (id) => WISH_TYPES.find(t => t.id === id);
const wishImageUrl = (path, bust) =>
  `${window.SUPABASE_URL}/storage/v1/object/public/avatars/${path}?bust=${bust || 0}`;


// ---- Meeting structure (โครงสร้างการประชุม) ----
const MEETINGS = [
  {
    id: "morning-brief",
    name: "Morning Brief",
    cadence: "ทุกวันทำการ",
    time: "08:30–09:30",
    duration: "60 นาที",
    audience: "ผู้จัดการ Unit + ตัวแทน",
    owner: "ผู้จัดการสำนักงาน + ทีม Morning Mindset",
    agenda: [
      "Mindset & Quote of the day",
      "Sales board ของวันก่อน (APE / Activity)",
      "Focus ของวันนี้: นัด/ปิด/บริการ",
      "Announcement / Contest standing",
    ],
    pillar: "m",
  },
  {
    id: "weekly-coach",
    name: "Weekly Coaching · 1-on-1",
    cadence: "ทุกสัปดาห์",
    time: "นัดส่วนตัว",
    duration: "30–45 นาที/คน",
    audience: "ผจก. Unit ↔ ตัวแทน",
    owner: "ผู้จัดการ Unit แต่ละ Unit",
    agenda: [
      "Activity Sheet สัปดาห์ที่ผ่านมา",
      "Pipeline review (Hot / Warm / Cold)",
      "แก้ปัญหา / Roleplay กรณีจริง",
      "ตั้งเป้า + commit สำหรับสัปดาห์หน้า",
    ],
    pillar: "s",
  },
  {
    id: "weekly-manager",
    name: "Weekly Manager Sync",
    cadence: "ทุกวันจันทร์",
    time: "10:00–11:30",
    duration: "90 นาที",
    audience: `ผู้จัดการ ${TARGET.units.toLocaleString("en-US")} Unit`,
    owner: "ผู้จัดการสำนักงาน",
    agenda: [
      "Scorecard แต่ละ Unit (APE / Recruit / Active)",
      "Gap analysis vs. เป้ารายไตรมาส",
      "แผนงาน 1 สัปดาห์ + กิจกรรมที่ต้องผลัก",
      "Best practice แลกเปลี่ยน",
    ],
    pillar: "s",
  },
  {
    id: "monthly-review",
    name: "Monthly Business Review (MBR)",
    cadence: "ทุกสิ้นเดือน",
    time: "13:00–17:00",
    duration: "4 ชม.",
    audience: "ผู้บริหารสำนักงาน + ผจก. Unit ทั้งหมด",
    owner: "ผู้จัดการสำนักงาน",
    agenda: [
      "Performance Review: APE / Headcount / Active Agent",
      "Pipeline & Forecast เดือนถัดไป",
      "Recognition ผู้ทำผลงานเด่น",
      "ปรับ Roadmap + Contest กลยุทธ์",
    ],
    pillar: "s",
  },
];

// ---- Tracking & Review system ----
const TRACKING = [
  { layer: "Daily", who: "ตัวแทน", what: "Activity Sheet — นัด/พบ/เสนอ/ปิด", tool: "Line group + Sheet" },
  { layer: "Daily", who: "ผจก. Unit", what: "APE board · Activity ratio", tool: "Dashboard สำนักงาน" },
  { layer: "Weekly", who: "ผจก. Unit", what: "Pipeline review · 1-on-1 coaching", tool: "Coaching log" },
  { layer: "Weekly", who: "ผู้บริหารสำนักงาน", what: `Scorecard ${TARGET.units.toLocaleString("en-US")} Unit · Gap vs target`, tool: "Weekly sync deck" },
  { layer: "Monthly", who: "ทุกระดับ", what: "MBR — Performance + Forecast + Recognition", tool: "MBR report" },
  { layer: "Quarterly", who: "ผู้บริหารสำนักงาน", what: "Strategy review · Contest result · Roadmap update", tool: "QBR deck" },
];

// ---- Contest & Incentive plan ----
const CONTESTS = [
  {
    q: "Phase 1",
    name: "New Star Launch",
    period: "มิ.ย.–ก.ค. 2569",
    focus: "Kick-off · สรรหาตัวแทนใหม่ + Activate ratio",
    prize: "Trip ภายในประเทศ + Recognition",
    pillar: "m",
  },
  {
    q: "Phase 2",
    name: "Production Push",
    period: "ส.ค.–ก.ย. 2569",
    focus: "Top 10 per unit · ผลัก production สูงสุดของปี",
    prize: "โบนัสพิเศษ + เข็มเกียรติยศ",
    pillar: "m",
  },
  {
    q: "Phase 3",
    name: "Comeback Quarter",
    period: "ต.ค.–พ.ย. 2569",
    focus: "Reactivate + Renewal + ยอดต่อเนื่อง ถึง 24M",
    prize: "Voucher + ตั๋ว Convention",
    pillar: "m",
  },
  {
    q: "Phase 4",
    name: "Finish Strong Gala",
    period: "ธ.ค. 2569",
    focus: "ปิดเป้า · Top Unit Award",
    prize: "Trip ต่างประเทศ + Year-End Gala",
    pillar: "m",
  },
];

// Sandbox: set to "YYYY-MM-DD" to override today's date for testing, null for production
const SANDBOX_DATE = null;

const getNow = () => SANDBOX_DATE ? new Date(SANDBOX_DATE + "T12:00:00") : new Date();
const dateKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;

// ---- เป้าหมายใหญ่ (org_goals) — แผน active จาก DB ใช้แทนค่า TARGET/BASELINE/APE_MONTHLY_PROGRESS
//      ที่ฝังใน config.js/data.jsx บนหน้า landing (ถ้าไม่มีแผน active → fallback ค่าเดิมทุกจุด) ----
/* @org-goal-helpers-start */
// GA83F ไม่มี TH_MONTHS ใน data.jsx (มี copy รายไฟล์) — ใช้ array ของ helper เองกันชนกัน
const ORG_TH_MONTHS = ["ม.ค.","ก.พ.","มี.ค.","เม.ย.","พ.ค.","มิ.ย.","ก.ค.","ส.ค.","ก.ย.","ต.ค.","พ.ย.","ธ.ค."];
// รายการเดือน 'YYYY-MM' ตั้งแต่ start ถึง end (รวมปลายทาง) — จำกัด 60 เดือนกันช่วงผิดพลาด
const monthKeysBetween = (start, end) => {
  if (!/^\d{4}-\d{2}$/.test(start || "") || !/^\d{4}-\d{2}$/.test(end || "")) return [];
  const keys = [];
  let [y, m] = start.split("-").map(Number);
  for (let i = 0; i < 60; i++) {
    const key = `${y}-${String(m).padStart(2, "0")}`;
    keys.push(key);
    if (key === end) return keys;
    m += 1; if (m > 12) { m = 1; y += 1; }
  }
  return keys[keys.length - 1] === end ? keys : [];
};
// "ม.ค. 2570" จาก 'YYYY-MM'
const orgMonthLabel = (key) => {
  const [y, m] = String(key || "").split("-");
  return ORG_TH_MONTHS[Number(m) - 1] ? `${ORG_TH_MONTHS[Number(m) - 1]} ${Number(y) + 543}` : "-";
};
// "ม.ค. – ธ.ค. 2570" / "มิ.ย. 2569 – ธ.ค. 2570" (ละปีซ้ำเมื่ออยู่ปีเดียวกัน)
const orgGoalRangeLabel = (goal) => {
  if (!goal) return "";
  const [sy] = String(goal.start_month || "").split("-");
  const [ey] = String(goal.end_month || "").split("-");
  const s = orgMonthLabel(goal.start_month), e = orgMonthLabel(goal.end_month);
  if (sy === ey) return `${s.split(" ")[0]} – ${e}`;
  return `${s} – ${e}`;
};
/* @org-goal-helpers-end */
// โหลดแผน active + เป้ารายเดือน — คืน null เมื่อไม่มีแผน/ตารางยังไม่ถูกสร้าง (ให้ fallback ทำงาน)
const loadActiveOrgGoal = async (client) => {
  try {
    const { data: goal, error } = await client.from("org_goals").select("*")
      .eq("status", "active").maybeSingle();
    if (error || !goal) return null;
    const { data: months } = await client.from("org_goal_months")
      .select("month, target, focus").eq("goal_id", goal.id).order("month");
    return { ...goal, months: months || [] };
  } catch {
    return null;
  }
};

// Preset cartoon avatars users (or admins) can pick instead of uploading a photo.
// ---- คุณวุฒิประจำปี (qualification_rounds/tiers) — เกณฑ์มาจาก DB
//      ความคืบหน้าคำนวณสดจาก agent_ape_sales นับเฉพาะแถวที่ approved = true ----
/* @qualification-helpers-start */
// ชนิดเกณฑ์ที่ขั้นคุณวุฒิเลือกได้ — key ตรงกับคอลัมน์ qualification_tiers.metric
// 'fyc' คือชื่อคอลัมน์เดิมในฐานข้อมูล ส่วนที่ผู้ใช้เห็นเรียก "NBC" เสมอ
const QUAL_METRICS = [
  { key: "ape", label: "เบี้ย (APE)",     short: "เบี้ย",    col: "amount" },
  { key: "fyc", label: "ค่าคอมฯ (NBC)",   short: "ค่าคอมฯ",  col: "fyc"    },
];
const QUAL_METRIC_MAP = { ape: QUAL_METRICS[0], fyc: QUAL_METRICS[1] };
// ขั้นที่ไม่ระบุชนิด (ข้อมูลที่ตั้งไว้ก่อนมีฟีเจอร์นี้) ให้ถือเป็นเบี้ยเสมอ
const qualMetricOf = (tier) => (tier && tier.metric === "fyc" ? "fyc" : "ape");
const qualMetricLabel = (key) => (QUAL_METRIC_MAP[key] || QUAL_METRIC_MAP.ape).short;

// บวกเดือนให้คีย์ 'YYYY-MM' (n ติดลบได้) — คืน "" เมื่อรูปแบบผิด
const qualAddMonths = (key, n) => {
  if (!/^\d{4}-\d{2}$/.test(String(key || ""))) return "";
  const [y, m] = key.split("-").map(Number);
  const total = y * 12 + (m - 1) + (Number(n) || 0);
  return `${Math.floor(total / 12)}-${String((total % 12) + 1).padStart(2, "0")}`;
};

// รวม "ยอดนำส่ง" ในรอบ ทั้งเบี้ย (amount) และค่าคอมฯ (fyc)
// คุณวุฒินับทุกดีลที่ปิดแล้ว ไม่ต้องรอผู้จัดการอนุมัติ จึงไม่แยกอนุมัติ/รออนุมัติอีก
// (ต่างจากแข่งขันทีมและคะแนน NBC ที่ยังตัดสินด้วยยอดอนุมัติเหมือนเดิม)
// sales = แถวจาก agent_ape_sales · agentId = null แปลว่ารวมทุกคน
const qualSums = (sales, round, agentId) => {
  const out = { ape: 0, fyc: 0 };
  const s = String(round?.start_month || ""), e = String(round?.end_month || "");
  if (!/^\d{4}-\d{2}$/.test(s) || !/^\d{4}-\d{2}$/.test(e)) return out;
  (sales || []).forEach((row) => {
    if (agentId != null && row.agent_id !== agentId) return;
    const key = String(row.date || "").slice(0, 7);
    if (key < s || key > e) return;
    out.ape += Number(row.amount) || 0;
    out.fyc += Number(row.fyc) || 0;
  });
  return out;
};

// ยอดนำส่งของขั้นหนึ่ง ตามชนิดที่ขั้นนั้นใช้
const qualTierSum = (sums, tier) =>
  (sums && sums[qualMetricOf(tier)]) || 0;

// เรียงขั้น: กลุ่มเบี้ยก่อนกลุ่มค่าคอมฯ แล้วเรียงตามเกณฑ์ภายในกลุ่มเดียวกัน
// แยกกลุ่มก่อนเพราะเกณฑ์คนละชนิดเทียบตัวเลขกันตรง ๆ ไม่ได้
// (ค่าคอมฯ 500,000 ไม่ได้ "ง่ายกว่า" เบี้ย 2,150,000 แม้ตัวเลขจะน้อยกว่า)
// sort_order เป็นตัวตัดสินท้ายสุดเมื่อชนิดและเกณฑ์เท่ากัน
const qualSortTiers = (tiers) =>
  (tiers || []).slice().sort((a, b) =>
    (qualMetricOf(a) === "fyc" ? 1 : 0) - (qualMetricOf(b) === "fyc" ? 1 : 0)
    || (Number(a.threshold) || 0) - (Number(b.threshold) || 0)
    || (Number(a.sort_order) || 0) - (Number(b.sort_order) || 0));

// ความคืบหน้าของขั้นหนึ่งเป็นเปอร์เซ็นต์ — ยังไม่ถึงเกณฑ์ต้องไม่โชว์ 100% เด็ดขาด
const qualTierPct = (sums, tier) => {
  const th = Number(tier?.threshold) || 0;
  if (th <= 0) return 0;
  const v = qualTierSum(sums, tier);
  return v >= th ? 100 : Math.min(99, Math.round((v / th) * 100));
};

// ขั้นสูงสุดที่ถึงแล้ว — null = ยังไม่ถึงขั้นไหนเลย
const qualReachedTier = (tiers, sums) => {
  let reached = null;
  qualSortTiers(tiers).forEach((t) => {
    if (qualTierSum(sums, t) >= (Number(t.threshold) || 0)) reached = t;
  });
  return reached;
};

// ขั้นถัดไป = ขั้นที่ยังไม่ถึงและ "ใกล้ที่สุด" (เปอร์เซ็นต์สูงสุด)
// ใช้ความใกล้แทนลำดับ เพราะขั้นคนละชนิดเรียงเทียบกันไม่ได้
// เสมอกันให้เอาตัวแรกตามลำดับ — กรณีทุกขั้นยังเป็นเบี้ยหมด ผลลัพธ์เท่ากับ "ขั้นต่ำสุดที่ยังไม่ถึง" เหมือนเดิม
const qualNextTier = (tiers, sums) => {
  const unreached = qualSortTiers(tiers)
    .filter((t) => qualTierSum(sums, t) < (Number(t.threshold) || 0));
  if (!unreached.length) return null;
  return unreached.reduce((best, t) =>
    (qualTierPct(sums, t) > qualTierPct(sums, best) ? t : best), unreached[0]);
};

// จำนวนเดือนที่ผ่านไปในรอบ นับรวมเดือนปัจจุบัน — อย่างน้อย 1 เสมอเพื่อกันหารศูนย์
const qualElapsedMonths = (round, nowKey) => {
  const s = String(round?.start_month || ""), e = String(round?.end_month || "");
  if (!/^\d{4}-\d{2}$/.test(s) || !/^\d{4}-\d{2}$/.test(e)) return 0;
  const cur = nowKey < s ? s : (nowKey > e ? e : nowKey);
  const [sy, sm] = s.split("-").map(Number);
  const [cy, cm] = cur.split("-").map(Number);
  return Math.max(1, (cy - sy) * 12 + (cm - sm) + 1);
};

// ความคืบหน้าของตัวแทนหนึ่งคน — ใช้ทั้งหน้าตัวแทนและหน้า admin/manager
// submitted/pace/needed อ้างอิง "ชนิดของขั้นถัดไป" เพื่อให้หน้าจอโชว์ตัวเลขชุดเดียวกันทั้งการ์ด
// ส่วน ape/fyc ส่งไปครบทั้งคู่สำหรับหน้าที่ต้องโชว์สองชนิดพร้อมกัน
const qualProgress = (sales, round, tiers, agentId, nowKey) => {
  const sums = qualSums(sales, round, agentId);
  const reached = qualReachedTier(tiers, sums);
  const next = qualNextTier(tiers, sums);
  const focus = next || reached;
  const metric = qualMetricOf(focus);
  const submitted = sums[metric];
  const elapsed = qualElapsedMonths(round, nowKey);
  const pace = elapsed > 0 ? submitted / elapsed : 0;
  const needed = next ? Math.max(0, (Number(next.threshold) || 0) - submitted) : 0;
  // ไม่มีขั้นถัดไปแล้วมี 2 กรณี: ถึงขั้นสูงสุดจริง (100) กับยังไม่ได้ตั้งขั้นเลย (0)
  const pct = next ? qualTierPct(sums, next) : (reached ? 100 : 0);
  let etaMonth = null;
  if (next && pace > 0) {
    const eta = qualAddMonths(round.start_month, elapsed - 1 + Math.ceil(needed / pace));
    if (eta && eta <= String(round.end_month)) etaMonth = eta;
  }
  return { sums, ape: sums.ape, fyc: sums.fyc, metric,
           submitted, reached, next, needed, pct, pace, etaMonth };
};

// ผู้เล่นทั้งหมด (ตัด role admin ออก แต่ยังนับ manager เป็นผู้เล่น)
const qualPlayers = (agents) => (agents || []).filter((a) => a.role !== "admin");

// หนึ่งแถวต่อผู้เล่น เรียงยอดเบี้ยมากไปน้อย — ใช้ในตารางภาพรวมฝั่งแอดมิน/ผู้จัดการ
// เรียงด้วยยอดเบี้ยเสมอเพื่อให้ลำดับคงที่ ไม่แกว่งตามชนิดของขั้นถัดไปของแต่ละคน
const qualAgentRows = (sales, agents, round, tiers, nowKey) =>
  qualPlayers(agents)
    .map((a) => ({ ...qualProgress(sales, round, tiers, a.id, nowKey),
                   id: a.id, name: a.name, unit: a.unit, agent: a }))
    .sort((x, y) => y.ape - x.ape);

// กระดาน "ใครถึงคุณวุฒิแล้วบ้าง" — จัดกลุ่มตามขั้นสูงสุดที่แต่ละคนถึง เรียงขั้นสูงลงต่ำ
// value ของแต่ละคนคือยอดของ "ชนิดที่ขั้นนั้นใช้" ไม่ใช่ยอดเบี้ยเสมอไป
// passed = จำนวนคนที่ยอดถึงเกณฑ์ขั้นนี้ (รวมคนที่เลื่อนไปโชว์ในขั้นสูงกว่าแล้ว)
// topBelow = ยอดสูงสุดของคนที่ยังไม่ถึงเกณฑ์ขั้นนี้ (0 = ไม่มีใครอยู่ใต้เกณฑ์)
const qualBoard = (sales, agents, round, tiers) => {
  const rows = qualPlayers(agents).map((a) => {
    const sums = qualSums(sales, round, a.id);
    return { id: a.id, name: a.name, sums, tier: qualReachedTier(tiers, sums) };
  });
  const groups = qualSortTiers(tiers).slice().reverse().map((t) => {
    const bar = Number(t.threshold) || 0;
    const valOf = (r) => qualTierSum(r.sums, t);
    const below = rows.filter((r) => valOf(r) < bar);
    return {
      tier: t,
      metric: qualMetricOf(t),
      people: rows.filter((r) => r.tier && r.tier.id === t.id)
        .map((r) => ({ id: r.id, name: r.name, value: valOf(r) }))
        .sort((x, y) => y.value - x.value),
      passed: rows.length - below.length,
      topBelow: below.reduce((mx, r) => Math.max(mx, valOf(r)), 0),
    };
  });
  return { groups, topValue: rows.reduce((mx, r) => Math.max(mx, r.sums.ape), 0) };
};

// จัดรูปยอดเงินในหน้าคุณวุฒิ — คั่นหลักพันแบบสากลเพื่อให้ตัวเลขยาว ๆ อ่านง่าย
const qualMoney = (n) => Number(n || 0).toLocaleString("en-US");

// ตราคุณวุฒิเป็นเครื่องหมายการค้า MDRT — admin ตั้งชื่อขั้นเองได้
// ตราจึงขึ้นเฉพาะขั้นที่ชื่อตรงกับคุณวุฒิจริง ไม่ใช่ทุกขั้นที่ admin พิมพ์
const QUAL_CREST_LABELS = ["MDRT", "COT", "TOT"];
const qualHasCrest = (label) =>
  QUAL_CREST_LABELS.indexOf(String(label || "").trim().toUpperCase()) !== -1;

// แถบความคืบหน้า "รายคน" ของหน้า admin/manager — วัดระยะจากยอดนำส่งไปถึงขั้นถัดไป
// ต่างจาก QualificationBar ของหน้าตัวแทนที่วัดทีละขั้น เพราะที่นี่หนึ่งแถวคือหนึ่งคน
// row = สมาชิกหนึ่งตัวจาก qualAgentRows
const qualRowBar = (row) => {
  if (!row) return { metric: "ape", done: false, pct: 0, submitted: 0, remaining: 0, target: null };
  const { metric, submitted, next, reached } = row;
  // ไม่มีขั้นถัดไป = ครบทุกขั้นแล้ว (แถบเต็ม) หรือรอบนี้ยังไม่ได้ตั้งขั้นเลย (แถบว่าง)
  if (!next) {
    return { metric, done: !!reached, pct: reached ? 100 : 0,
             submitted, remaining: 0, target: reached || null };
  }
  const threshold = Number(next.threshold) || 1;
  return { metric, done: false, pct: Math.min(100, (submitted / threshold) * 100),
           submitted, remaining: Math.max(0, threshold - submitted), target: next };
};
/* @qualification-helpers-end */

// ตราประจำขั้น — photos/mdrt-crest.png คือไฟล์ต้นฉบับ photos/MDRT.png ที่ย่อเหลือ 320px
// (ต้นฉบับ 673px/397KB หนักเกินไปสำหรับไอคอน 26px — ย่อสัดส่วนเดิม ไม่บิด ไม่ย้อมสี)
// (เป็นเครื่องหมายการค้า ห้ามวาดใหม่/ย้อมสี — "ล็อก" ใช้แค่ grayscale + ลด opacity)
// อยู่ใน data.jsx เพราะทั้งหน้าตัวแทน (login-app) และหน้า admin/manager (admin-app) ใช้ร่วมกัน
const QualCrest = ({ label, reached, size = 26 }) => {
  if (!qualHasCrest(label)) return null;
  return (
    <img className={`qual-crest${reached ? "" : " locked"}`} src="photos/mdrt-crest.png"
         width={size} height={size} alt="" aria-hidden="true"
         onError={(e) => { e.currentTarget.style.display = "none"; }} />
  );
};

// โหลดรอบคุณวุฒิ active + ขั้นทั้งหมด — คืน null เมื่อไม่มีรอบ/ตารางยังไม่ถูกสร้าง
const loadActiveQualificationRound = async (client) => {
  try {
    const { data: round, error } = await client.from("qualification_rounds").select("*")
      .eq("status", "active").maybeSingle();
    if (error || !round) return null;
    const { data: tiers } = await client.from("qualification_tiers")
      .select("id, label, metric, threshold, sort_order").eq("round_id", round.id)
      .order("sort_order").order("threshold");
    return { ...round, tiers: tiers || [] };
  } catch {
    return null;
  }
};

const CARTOON_AVATARS = [
  "photos/cartoon-male-1.svg",
  "photos/cartoon-male-2.svg",
  "photos/cartoon-female-1.svg",
  "photos/cartoon-female-2.svg",
  "photos/cartoon-other-1.svg",
  "photos/cartoon-other-2.svg",
];

// ---- My Money Needs worksheet — ค่าเริ่มต้น + สูตรแปลงเป้ารายได้เป็นกิจกรรมการขาย ----
const MONEY_NEEDS_DEFAULTS = { commission: 30, caseSize: 25000, rows: 9 };
// อัตราส่วน funnel ตามฟอร์ม: ราย ×3 → พยายามปิด ×1.7 → นำเสนอ ×2 → นัดหมาย (≈ 10:5:3:1)
const moneyNeedsCalc = (g) => {
  const items = Array.isArray(g?.expense_items) ? g.expense_items : [];
  const expenseTotal  = items.reduce((s, e) => s + (Number(e?.amount) || 0), 0);
  const savings       = Number(g?.savings_target) || 0;
  const requiredIncome = expenseTotal + savings;                         // รวมรายได้ที่ต้องการ/เดือน
  const commission    = Number(g?.commission_rate) || 0;                 // %
  const caseSize      = Number(g?.case_size) || 0;
  const fyp           = commission > 0 ? requiredIncome / (commission / 100) : 0;  // FYP/เดือน
  const cases         = caseSize > 0 ? Math.ceil(fyp / caseSize) : 0;    // จำนวนราย/เดือน (= ปิดได้)
  const closeAttempts = cases * 3;                                       // พยายามปิดการขาย
  const presentations = Math.ceil(closeAttempts * 1.7);                  // นำเสนอขาย
  const appointments  = presentations * 2;                               // นัดหมาย
  const wk = (n) => Math.ceil(n / 4.33);                                 // ต่อสัปดาห์
  return {
    expenseTotal, savings, requiredIncome, commission, caseSize, fyp, cases,
    closeAttempts, presentations, appointments,
    weekly: { appointments: wk(appointments), presentations: wk(presentations), closeAttempts: wk(closeAttempts), closes: wk(cases) },
    hasData: requiredIncome > 0,
  };
};

// ---- คะแนนต่อกิจกรรม (point_rules) — ค่าเริ่มต้น = fallback เดิมที่เคย hardcode ----
// mutate object เดิมในที่เดียวหลัง loadPointRules() resolve (ไม่สร้าง object ใหม่) เพื่อให้
// ทุกจุดที่อ่าน POINT_RULES.xxx เห็นค่าล่าสุดโดยไม่ต้อง re-export ซ้ำ — เรียก loadPointRules()
// ครั้งเดียวตอน root component mount (ดู admin-app.jsx AdminApp / login-app.jsx App)
const POINT_RULES = { checkin: 10, lms: 5, deal: 5, money_need: 50, fyc: 100, recruit: 1000 };
let pointRulesLoadPromise = null;
function loadPointRules() {
  if (!pointRulesLoadPromise) {
    pointRulesLoadPromise = sb.from("point_rules").select("rule_key, points, active")
      .then(function (res) {
        (res.data || []).forEach(function (r) {
          if (r.rule_key && r.rule_key in POINT_RULES) {
            POINT_RULES[r.rule_key] = r.active ? r.points : 0;
          }
        });
      })
      .catch(function () { /* table ยังไม่ถูกสร้าง หรือ fetch fail — เก็บ fallback เดิมไว้ */ });
  }
  return pointRulesLoadPromise;
}

// ---- คลับประจำจาก DB (class_schedules) — แปลง row → club object ใช้ร่วมทุกหน้า (port จาก 83G30M) ----
// โปสเตอร์คลาสอยู่ใน bucket avatars เหมือน classPosterSrc (rewards.jsx) — path ใน DB มี prefix class-posters/ แล้ว
const clubPosterFrom = (path) =>
  !path ? null
  : /^(photos\/|https?:|data:)/.test(path) ? path
  : `${window.SUPABASE_URL}/storage/v1/object/public/avatars/${path}`;
const clubFromRow = (row) => {
  const specificDate = row.specific_date || null;
  const arr = Array.isArray(row.day_idx) ? row.day_idx.map(Number).filter(Boolean) : null;
  const dayIdx = (arr && arr.length)
    ? (arr.length === 1 ? arr[0] : arr)
    : (specificDate ? (new Date(`${specificDate}T00:00:00`).getDay() || 7) : Number(row.day_of_week));
  return {
    id: `admin-${row.id}`,
    rowId: row.id,
    name: row.title,
    desc: row.description || row.note || "",
    day: row.day_label || "",
    dayIdx,
    weeks: row.weeks || "ทุกสัปดาห์",
    perMonth: row.per_month != null ? Number(row.per_month) : 4,
    time: row.time,
    channel: row.channel || "Online",
    owners: Array.isArray(row.owners) ? row.owners : [],
    poster: clubPosterFrom(row.poster_path),
    pillar: row.pillar || "t",
    tag: row.tag || "T",
    activeFrom: row.active_from || null,
    skipDates: Array.isArray(row.skip_dates) ? row.skip_dates : [],
    spotlight: row.spotlight || undefined,
    zoomEnabled: row.zoom_enabled !== false,
    checkinEnabled: row.checkin_enabled !== false,
    sortOrder: row.sort_order != null ? Number(row.sort_order) : 100,
    adminManaged: true,
    ...(specificDate ? { date: specificDate, activeFrom: specificDate, activeUntil: specificDate } : {}),
  };
};

/* @capacity-helpers-start */
// ---- ความจุระบบ (System Capacity) — helpers ล้วน ไม่มี dependency ----
const CAPACITY_GB = 1024 * 1024 * 1024;
const fmtBytes = (bytes) => {
  if (bytes == null) return "—";
  if (bytes >= CAPACITY_GB) return (bytes / CAPACITY_GB).toFixed(2) + " GB";
  if (bytes >= 1024 * 1024) return Math.round(bytes / (1024 * 1024)) + " MB";
  if (bytes >= 1024) return Math.round(bytes / 1024) + " KB";
  return bytes + " B";
};
// เขียว < 70% · เหลืองทอง 70–90% · แดง > 90% (ตาม spec)
const capacityLevel = (ratio) => ratio > 0.9 ? "red" : ratio >= 0.7 ? "gold" : "green";
// ยอดฐานข้อมูลที่โชว์ = data + WAL (WAL อ่านไม่ได้ → null → นับเฉพาะ data)
const capacityDbBytes = (cap) => ((cap && cap.db_bytes) || 0) + ((cap && cap.wal_bytes) || 0);
/* @capacity-helpers-end */

/* @orphan-helpers-start */
// ---- ล้างไฟล์กำพร้า (Orphan cleanup) — helpers ล้วน ----
const orphanKey = (f) => `${f.bucket_id}/${f.name}`;
const chunkArray = (arr, size) => {
  const step = Math.max(1, size);
  const out = [];
  for (let i = 0; i < arr.length; i += step) out.push(arr.slice(i, i + step));
  return out;
};
const orphanSelectionSummary = (files, selectedKeys) =>
  files.reduce((acc, f) => selectedKeys.has(orphanKey(f))
    ? { count: acc.count + 1, bytes: acc.bytes + (f.bytes || 0) }
    : acc, { count: 0, bytes: 0 });
const groupOrphansByBucket = (files) => {
  const map = new Map();
  for (const f of files) {
    const g = map.get(f.bucket_id) || { bucket_id: f.bucket_id, files: [], bytes: 0, count: 0 };
    g.files.push(f); g.bytes += (f.bytes || 0); g.count += 1;
    map.set(f.bucket_id, g);
  }
  return [...map.values()].sort((a, b) => b.bytes - a.bytes);
};
// ผลการล้างไฟล์กำพร้า → { type, message } สำหรับ toast — รายงานจำนวนที่ลบ "จริง"
// (Supabase remove() ที่ติด RLS จะคืน 0 ไฟล์แบบไม่ error → ต้องแจ้งว่าไม่สำเร็จ ไม่ใช่ขึ้นเขียว)
const orphanCleanupResult = (requested, deleted, reclaimedBytes, firstError) => {
  if (!firstError && deleted >= requested) {
    return { type: "ok", message: `ลบไฟล์กำพร้าแล้ว ${deleted} ไฟล์ · คืนพื้นที่ ${fmtBytes(reclaimedBytes)}` };
  }
  const base = `ลบได้ ${deleted}/${requested} ไฟล์`;
  if (firstError) return { type: "error", message: `${base} · ${firstError}` };
  const hint = deleted === 0
    ? " — Storage ปฏิเสธการลบทั้งหมด มักเพราะยังไม่ได้ตั้งสิทธิ์ลบ (RLS delete policy) ของ bucket · รัน supabase-storage-delete-policies.sql"
    : " — บางไฟล์ถูกปฏิเสธ (อาจติดสิทธิ์ลบบาง bucket)";
  return { type: "error", message: base + hint };
};
/* @orphan-helpers-end */

Object.assign(window, {
  CAPACITY_GB, fmtBytes, capacityLevel, capacityDbBytes, orphanKey, chunkArray, orphanSelectionSummary, groupOrphansByBucket, orphanCleanupResult,
  BRAND, TARGET, BASELINE, POINT_RULES, loadPointRules, PILLARS, CLUBS, clubFromRow, clubPosterFrom, ACTIVITIES, CALENDAR_EVENTS, LMS_LESSONS, MONTHS, NAV,
  MEETINGS, TRACKING, CONTESTS, AGENT_NAV, APE_MONTHLY_PROGRESS, APE_RATE, SANDBOX_DATE, getNow, dateKey, SEASON_START,
  monthKeysBetween, orgMonthLabel, orgGoalRangeLabel, loadActiveOrgGoal,
  QUAL_METRICS, QUAL_METRIC_MAP, qualMetricOf, qualMetricLabel,
  qualAddMonths, qualSums, qualTierSum, qualTierPct, qualSortTiers,
  qualReachedTier, qualNextTier, qualElapsedMonths, qualProgress,
  qualPlayers, qualAgentRows, qualBoard, loadActiveQualificationRound,
  qualMoney, QUAL_CREST_LABELS, qualHasCrest, qualRowBar, QualCrest,
  WISH_TYPES, wishTypeOf, wishImageUrl, NAV_ICONS, NavIcon, CARTOON_AVATARS,
  SALES_STAGES, SALES_STAGE_MAP, SALES_OPEN_STAGES, salesStageLabel,
  SALES_LOST_REASONS, SALES_VISIT_OUTCOMES, SALES_GENDERS, SALES_INCOME_RANGES, SALES_INTERESTS, SALES_HEALTH_LEVELS, SALES_RESCHEDULE_REASONS, salesVisitUrl,
  RECRUIT_STAGES, RECRUIT_STAGE_MAP, RECRUIT_OPEN_STAGES, RECRUIT_APPT_STAGES, recruitStageLabel,
  RECRUIT_SOURCES, RECRUIT_DROP_REASONS, RECRUIT_RESCHEDULE_REASONS, recruitDocUrl,
  MONEY_NEEDS_DEFAULTS, moneyNeedsCalc,
});
