/* global React, ReactDOM */
// Primitive components — Button, Field, Tag, Checkbox, Icon, Toast, InfoTip, TruncCell
// Exposed on `window` so other Babel scripts can use them.

const { useState, useEffect, useRef, useMemo, useCallback, createContext, useContext } = React;

/* ---------- Icon (inline lucide-style SVGs; stroke 1px @ size<24, 1.6px @ size>=24) ---------- */
const Icon = ({ name, size = 16, ...rest }) => {
  const sw = size >= 24 ? 1.6 : 1;
  const paths = {
    search:   <><circle cx="11" cy="11" r="7"/><path d="M21 21l-5-5"/></>,
    plus:     <path d="M12 5v14M5 12h14"/>,
    minus:    <path d="M5 12h14"/>,
    close:    <path d="M6 6l12 12M18 6L6 18"/>,
    check:    <path d="M20 6L9 17l-5-5"/>,
    chevDown: <path d="M6 9l6 6 6-6"/>,
    chevUp:   <path d="M6 15l6-6 6 6"/>,
    chevLeft: <path d="M15 6l-6 6 6 6"/>,
    chevRight:<path d="M9 6l6 6-6 6"/>,
    edit:     <><path d="M4 20h4l10-10-4-4L4 16z"/><path d="M14 6l4 4"/></>,
    trash:    <><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></>,
    upload:   <><path d="M12 20v-12"/><path d="M5 14l7-7 7 7"/><path d="M4 4h16"/></>,
    download: <><path d="M12 4v12"/><path d="M5 10l7 7 7-7"/><path d="M4 20h16"/></>,
    filter:   <path d="M3 5h18l-7 9v6l-4-2v-4z"/>,
    refresh:  <><path d="M21 4v6h-6"/><path d="M21 10A9 9 0 1 0 18 18"/></>,
    eye:      <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></>,
    bell:     <><path d="M18 16V10a6 6 0 1 0-12 0v6l-2 2h16z"/><path d="M10 21h4"/></>,
    box:      <><path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 7v10l9 4 9-4V7"/></>,
    truck:    <><path d="M3 8h14l4 5v6h-2"/><path d="M15 19H9"/><path d="M5 19H3V8"/><circle cx="7" cy="19" r="2"/><circle cx="17" cy="19" r="2"/></>,
    clipboard:<><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></>,
    grid:     <><path d="M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z"/></>,
    cog:      <><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .4 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.4 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.9.4l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .4-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.4-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.4h0a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.4l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.4 1.9v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></>,
    user:     <><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></>,
    info:     <><circle cx="12" cy="12" r="10"/><path d="M12 11v5M12 8v.5"/></>,
    warn:     <><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.5"/></>,
    alertCircle: <><circle cx="12" cy="12" r="10"/><path d="M12 8v5M12 16v.5"/></>,
    arrowUp:  <path d="M12 19V5M5 12l7-7 7 7"/>,
    arrowDown:<path d="M12 5v14M19 12l-7 7-7-7"/>,
    panelLeft:<><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/></>,
    inbox:    <><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5 5h14l3 7v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6z"/></>,
    tag:      <><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.83z"/><circle cx="7.5" cy="7.5" r="1.2"/></>,
    menu:     <><path d="M3 6h18M3 12h18M3 18h18"/></>,
    shield:   <><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></>,
    sliders:  <><path d="M4 6h11M20 6h1M4 12h2M9 12h11M4 18h11M20 18h1"/><circle cx="17" cy="6" r="2"/><circle cx="7" cy="12" r="2"/><circle cx="17" cy="18" r="2"/></>,
    send:     <><path d="M22 2L11 13"/><path d="M22 2L15 22l-4-9-9-4z"/></>,
    lock:     <g transform="scale(0.0234375)" fill="currentColor" stroke="none"><path d="M224 448a32 32 0 0 0-32 32v384a32 32 0 0 0 32 32h576a32 32 0 0 0 32-32V480a32 32 0 0 0-32-32zm0-64h576a96 96 0 0 1 96 96v384a96 96 0 0 1-96 96H224a96 96 0 0 1-96-96V480a96 96 0 0 1 96-96"/><path d="M512 544a32 32 0 0 1 32 32v192a32 32 0 1 1-64 0V576a32 32 0 0 1 32-32m192-160v-64a192 192 0 1 0-384 0v64zM512 64a256 256 0 0 1 256 256v128H256V320A256 256 0 0 1 512 64"/></g>,
    file:     <><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></>,
    fileExcel:<><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M9 13l4 4M13 13l-4 4"/></>,
    more:     <><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="5" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.5" fill="currentColor" stroke="none"/></>,
    copy:     <><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></>,
    expand:   <><path d="M15 3h6v6M9 21H3v-6M21 3l-6 6M3 21l6-6"/></>,
    collapse: <><path d="M9 9H3M15 15h6M9 3v6M15 21v-6M3 3l6 6M21 21l-6-6"/></>,
    plusCircle: <><circle cx="12" cy="12" r="10"/><path d="M12 8v8M8 12h8"/></>,
    history:  <><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/><path d="M12 7v5l3 2"/></>,
    image:    <><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></>,
    sync:     <><path d="M17 1l4 4-4 4M3 11V9a4 4 0 0 1 4-4h14M7 23l-4-4 4-4M21 13v2a4 4 0 0 1-4 4H3"/></>,
    list:     <><path d="M8 6h13M8 12h13M8 18h13"/><circle cx="3.5" cy="6" r="1" fill="currentColor" stroke="none"/><circle cx="3.5" cy="12" r="1" fill="currentColor" stroke="none"/><circle cx="3.5" cy="18" r="1" fill="currentColor" stroke="none"/></>,
    mapPin:   <><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></>,
    layers:   <><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></>,
  };
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none"
      stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" {...rest}>
      {paths[name] || null}
    </svg>
  );
};

/* ---------- Button ---------- */
const Button = ({ variant = "default", size, icon, iconOnly, children, ...rest }) => {
  const cn = ["btn", `btn--${variant}`, size && `btn--${size}`, iconOnly && "btn--icon"]
    .filter(Boolean).join(" ");
  return (
    <button className={cn} {...rest}>
      {icon && <Icon name={icon} />}
      {children}
    </button>
  );
};

/* ---------- Field (input / select / search) ---------- */
const Field = React.forwardRef(({ leading, error, className = "", ...rest }, ref) => {
  const cls = "field" + (error ? " field--error" : "") + " " + className;
  if (leading) {
    return (
      <div className="field-group">
        <span className="leading"><Icon name={leading} /></span>
        <input ref={ref} className={cls} {...rest} />
      </div>
    );
  }
  return <input ref={ref} className={cls} {...rest} />;
});

const Select = ({ children, error, ...rest }) => (
  <select className={"field field--select" + (error ? " field--error" : "")} {...rest}>{children}</select>
);

/* ---------- Textarea ---------- */
// 全站多行輸入唯一元件。variant="default"（中性，field--textarea）/ "reject"（退回原因，紅底 reject-reason-input）。
// 禁止再手寫 <textarea className="field">（field 為單行樣式，會造成文字靠頂）。
const Textarea = ({ variant = "default", error, rows = 2, className = "", ...rest }) => (
  <textarea
    rows={rows}
    className={
      (variant === "reject" ? "reject-reason-input" : "field field--textarea")
      + (error ? " field--error" : "") + (className ? " " + className : "")
    }
    {...rest}
  />
);

/* ---------- Tag ---------- */
const STATUS = {
  draft:     { label: "草稿",   variant: "draft" },
  review:    { label: "送審",   variant: "review" },
  approved:  { label: "已通過", variant: "approved" },
  rejected:  { label: "已拒絕", variant: "rejected" },
  hold:      { label: "暫緩",   variant: "hold" },
  // New-product creation states (this product surface only)
  editing:   { label: "編輯中", variant: "draft" },
  submitted: { label: "已送出", variant: "review" },
  // 進價歷史總表 生效狀態
  active:    { label: "生效中", variant: "active" },
  expired:   { label: "已失效", variant: "expired" },
  // ADJ-A 批次聚合狀態（統一文案，見 specs/00 全站審核狀態機統整規範）
  adjDraft:      { label: "草稿",   variant: "draft"    },
  adjSubmitted:  { label: "待審核", variant: "review"   },
  // ADJ-A row 層級狀態
  adjPending:   { label: "待審核",   variant: "review" },
  adjApproved:  { label: "審核通過", variant: "approved" },
  adjRejected:  { label: "已退回", variant: "rejected" },
  // 審核流程設定 條件類型
  condField:    { label: "依欄位規則", variant: "teal" },
  condAll:      { label: "無條件觸發", variant: "draft" },
  // Collection Offer 審核狀態
  coDraft:       { label: "草稿",   variant: "draft"    },
  coUnderReview: { label: "待審核", variant: "review"   },
  coNoReview:    { label: "無需審核", variant: "approved" },
  coApproved:    { label: "審核通過", variant: "approved" },
  coRejected:    { label: "已退回", variant: "rejected" },
  // Eats Offer 審核狀態
  eoDraft:       { label: "草稿",   variant: "draft"    },
  eoUnderReview: { label: "待審核", variant: "review"   },
  eoNoReview:    { label: "無需審核", variant: "approved" },
  eoApproved:    { label: "審核通過", variant: "approved" },
  eoRejected:    { label: "已退回", variant: "rejected" },
  // 廠商基礎資料 formStatus + vendorStatus
  vendorDraft:     { label: "草稿",     variant: "draft"   },
  vendorActive:    { label: "合作中",   variant: "active"  },
  vendorSuspended: { label: "停止合作", variant: "expired" },
  // 廠商 Oracle 同步狀態
  oracleSynced:   { label: "已建檔", variant: "active"   },
  oraclePending:  { label: "待同步", variant: "review"   },
  oracleNotFound: { label: "查無資料", variant: "rejected" },
};
const Tag = ({ status, variant, children }) => {
  const s = STATUS[status];
  const v = variant || (s ? s.variant : "draft");
  return <span className={"tag tag--" + v}>{children || (s ? s.label : null)}</span>;
};

/* ---------- Checkbox ---------- */
const Check = ({ checked, indeterminate, onChange, disabled, readOnly }) => {
  const cn = "cb"
    + (checked ? (readOnly ? " cb--readonly" : " cb--active") : "")
    + (!checked && indeterminate ? " indeterminate" : "")
    + (disabled ? " disabled" : "");
  const style = readOnly ? { cursor: "default", pointerEvents: "none" } : undefined;
  return <span className={cn} style={style} role="checkbox" aria-checked={checked}
    onClick={() => !disabled && !readOnly && onChange && onChange(!checked)} />;
};

/* ---------- Toast bus ---------- */
const ToastCtx = createContext({ push: () => {} });
const useToast = () => useContext(ToastCtx);
const ToastProvider = ({ children }) => {
  const [items, setItems] = useState([]);
  const push = useCallback((toast) => {
    const id = Math.random().toString(36).slice(2);
    setItems((arr) => [...arr, { id, ...toast }]);
    setTimeout(() => setItems((arr) => arr.filter(t => t.id !== id)), toast.duration || 2400);
  }, []);
  return (
    <ToastCtx.Provider value={{ push }}>
      {children}
      <div className="toast-stack">
        {items.map(t => (
          <div key={t.id} className={"toast toast--" + (t.kind || "success")}>
            <Icon name={t.kind === "danger" ? "alertCircle" : t.kind === "warning" ? "warn" : "check"} />
            <span>{t.msg}</span>
          </div>
        ))}
      </div>
    </ToastCtx.Provider>
  );
};

/* ---------- Dialog ---------- */
const Dialog = ({ open, title, children, footer, onClose, maskStyle }) => {
  if (!open) return null;
  return (
    <div className="dialog-mask" style={maskStyle} onClick={onClose}>
      <div className="dialog" onClick={e => e.stopPropagation()}>
        <div className="dialog__head">
          <div className="dialog__title">{title}</div>
          <button className="drawer__close" onClick={onClose}><Icon name="close" /></button>
        </div>
        <div className="dialog__body">{children}</div>
        {footer && <div className="dialog__foot">{footer}</div>}
      </div>
    </div>
  );
};

/* ---------- BulkBar ---------- */
// 多選操作列。variant="default"（綠）用於非破壞性操作；variant="danger"（紅）用於刪除。
// 用法：<BulkBar label="已選 N 筆…" variant="danger"><Button…/><Button…/></BulkBar>
const BulkBar = ({ label, children, variant = "default" }) => (
  <div className={`toolbar__bulk${variant === "danger" ? " toolbar__bulk--danger" : ""}`}>
    <span>{label}</span>
    {children}
  </div>
);

/* ---------- ChipFilter ---------- */
// 下拉篩選 chip，支援單選、多選（multi）
// 選項 ≥ 6 時自動顯示搜尋框；multi 模式永遠顯示 footer（已選 N 項 / 全選 / 清除）
// 用法：<ChipFilter label="批次" value={v} options={[…]} onChange={setV} multi />
const ChipFilter = ({ label, value, options, onChange, multi }) => {
  const [open, setOpen] = useState(false);
  const [q, setQ] = useState("");

  // Normalize: options can be strings or { value, label } objects
  const normOpts = useMemo(() =>
    options.map(o => typeof o === "object" ? o : { value: o, label: String(o) }),
  [options]);

  const showSearch = normOpts.length >= 6;
  const structured = showSearch || multi;

  const labelOf = (v) => normOpts.find(o => o.value === v)?.label ?? v;

  const display = multi
    ? (value && value.length
        ? (value.length === 1 ? labelOf(value[0]) : `${labelOf(value[0])} 等 ${value.length}`)
        : null)
    : (value ? labelOf(value) : null);

  const filteredOptions = useMemo(() => {
    if (!showSearch || !q.trim()) return normOpts;
    const needle = q.trim().toLowerCase();
    return normOpts.filter(o => o.label.toLowerCase().includes(needle));
  }, [normOpts, q, showSearch]);

  const toggle = (opt) => {
    const v = opt.value;
    if (!multi) { onChange(v); setOpen(false); return; }
    const cur = value || [];
    if (cur.includes(v)) onChange(cur.filter(x => x !== v));
    else onChange([...cur, v]);
  };

  const clear = (e) => { e.stopPropagation(); onChange(multi ? [] : ""); };
  const clearAll = () => onChange(multi ? [] : "");
  const selectAll = () => onChange(normOpts.map(o => o.value));
  const selectedCount = multi ? (value || []).length : (value ? 1 : 0);
  const allSelected = multi && normOpts.length > 0 && normOpts.every(o => (value || []).includes(o.value));
  const close = () => { setOpen(false); setQ(""); };

  return (
    <div className="chip-wrap">
      <button
        type="button"
        className={"chip" + (display ? " chip--active" : "")}
        onClick={() => setOpen(o => !o)}
      >
        <span className="chip__lbl">{label}</span>
        {display && <span className="chip__val">{display}</span>}
        {display
          ? <span onClick={clear} className="chip__clear"><Icon name="close" size={12} /></span>
          : <Icon name="chevDown" size={12} />}
      </button>
      {open && (
        <>
          <div className="chip-mask" onClick={close} />
          <div className={"chip-pop" + (structured ? " chip-pop--searchable" : "")}>
            {showSearch && (
              <div className="chip-pop__search">
                <Field
                  leading="search"
                  placeholder={`搜尋${label}…`}
                  value={q}
                  autoFocus
                  onChange={(e) => setQ(e.target.value)}
                />
              </div>
            )}
            <div className="chip-pop__list">
              {filteredOptions.length === 0 ? (
                <div className="chip-pop__empty">查無{label}，請調整關鍵字</div>
              ) : filteredOptions.map(opt => {
                const on = multi ? (value || []).includes(opt.value) : value === opt.value;
                return (
                  <div key={opt.value} className={"chip-pop__item" + (on ? " on" : "")} onClick={() => toggle(opt)}>
                    {multi && <Check checked={on} onChange={() => toggle(opt)} />}
                    <span>{opt.label}</span>
                  </div>
                );
              })}
            </div>
            {multi && (
              <div className="chip-pop__foot">
                <span>已選 <b>{selectedCount}</b> 項</span>
                <div className="chip-pop__foot-actions">
                  {!q.trim() && (
                    <button type="button" className="chip-pop__clear" onClick={selectAll}>
                      {allSelected ? "已全選" : "全選"}
                    </button>
                  )}
                  <button
                    type="button"
                    className="chip-pop__clear"
                    disabled={selectedCount === 0}
                    onClick={clearAll}>
                    清除
                  </button>
                </div>
              </div>
            )}
          </div>
        </>
      )}
    </div>
  );
};

/* ---------- PortalSelect ---------- */
// Portal-based single/multi/grouped select dropdown.
// Uses chip-pop CSS — visually identical to ChipFilter L1 dropdowns.
// Single (multi=false): value=string, onChange(string), closes on select.
// Multi  (multi=true):  value=string[], onChange(string[]), stays open; footer shows 全選/清除.
// Grouped: options=[{ group:"label", options:[…] }, …] — group headers rendered inline.
// disabled: trigger styled as disabled, blocks interaction.
// Usage: <PortalSelect value={v} options={opts} onChange={fn} triggerClassName="field-trigger" />
const PortalSelect = ({ value, options, multi = false, onChange, placeholder = "—", searchPlaceholder = "搜尋…", triggerClassName, triggerStyle, disabled = false, expandDisplay = false }) => {
  const [pos, setPos]   = useState(null);
  const [q, setQ]       = useState("");
  const triggerRef      = useRef(null);
  const dropdownRef     = useRef(null);
  const searchRef       = useRef(null);

  const isGrouped = useMemo(() =>
    options.length > 0 && options[0] !== null && typeof options[0] === "object" && "group" in options[0],
  [options]);

  const normOpts = useMemo(() => {
    if (isGrouped) {
      return options.flatMap(g =>
        (g.options || []).map(o => typeof o === "object" ? o : { value: o, label: String(o) })
      );
    }
    return options.map(o => typeof o === "object" ? o : { value: o, label: String(o) });
  }, [options, isGrouped]);

  const showSearch = normOpts.length >= 6;
  const selected   = multi
    ? (Array.isArray(value) ? value : value ? [value] : [])
    : (value || null);
  const isOn     = (v) => multi ? selected.includes(v) : selected === v;
  const labelOf  = (v) => normOpts.find(o => o.value === v)?.label ?? v;
  const display  = multi
    ? (selected.length === 0 ? null
       : expandDisplay
         ? selected.map(v => labelOf(v)).join("、")
         : selected.length === 1 ? labelOf(selected[0]) : `${labelOf(selected[0])} 等 ${selected.length} 項`)
    : (selected ? labelOf(selected) : null);

  const filtered = useMemo(() => {
    if (!showSearch || !q.trim()) return normOpts;
    const needle = q.trim().toLowerCase();
    return normOpts.filter(o => o.label.toLowerCase().includes(needle));
  }, [normOpts, q, showSearch]);

  const openDropdown = () => {
    const rect = triggerRef.current.getBoundingClientRect();
    const maxH = Math.min(320, Math.max(120, window.innerHeight - rect.bottom - 8));
    setPos({ top: rect.bottom + 2, left: rect.left, minW: rect.width, maxH });
    setQ("");
  };
  const closeDropdown = () => { setPos(null); setQ(""); };

  const handleSelect = (opt) => {
    if (multi) {
      const cur = Array.isArray(value) ? value : [];
      onChange(cur.includes(opt.value) ? cur.filter(v => v !== opt.value) : [...cur, opt.value]);
    } else {
      onChange(opt.value);
      closeDropdown();
    }
  };

  useEffect(() => {
    if (!pos) return;
    if (searchRef.current) searchRef.current.focus();
    const onMouse  = (e) => {
      if (triggerRef.current?.contains(e.target)) return;
      if (dropdownRef.current?.contains(e.target)) return;
      closeDropdown();
    };
    const onScroll = (e) => { if (dropdownRef.current?.contains(e.target)) return; closeDropdown(); };
    document.addEventListener("mousedown", onMouse);
    document.addEventListener("scroll",    onScroll, true);
    return () => {
      document.removeEventListener("mousedown", onMouse);
      document.removeEventListener("scroll",    onScroll, true);
    };
  }, [pos]);

  const renderItem = (opt) => (
    <div key={opt.value} className={`chip-pop__item${isOn(opt.value) ? " on" : ""}`}
      onClick={() => handleSelect(opt)}>
      {multi
        ? <Check checked={isOn(opt.value)} onChange={() => {}} />
        : <input type="radio" readOnly checked={isOn(opt.value)}
            style={{ margin: 0, accentColor: "var(--brand-primary)", width: 13, height: 13, flexShrink: 0 }} />
      }
      <span>{opt.label}</span>
    </div>
  );

  const renderItems = () => {
    if (!isGrouped) return filtered.map(renderItem);
    const filteredSet = new Set(filtered.map(o => o.value));
    const result = [];
    options.forEach((g, gi) => {
      const groupItems = (g.options || [])
        .map(o => typeof o === "object" ? o : { value: o, label: String(o) })
        .filter(o => filteredSet.has(o.value));
      if (groupItems.length === 0) return;
      result.push(<div key={`grp-${gi}`} className="chip-pop__group-header">{g.group}</div>);
      groupItems.forEach(opt => result.push(renderItem(opt)));
    });
    return result;
  };

  return (
    <>
      <button ref={triggerRef} onClick={() => pos ? closeDropdown() : openDropdown()}
        className={[triggerClassName, pos ? "open" : ""].filter(Boolean).join(" ")}
        style={triggerStyle}
        disabled={disabled}>
        <span style={{ color: display ? "inherit" : "var(--fg-4)" }}>{display || placeholder}</span>
        <Icon name={pos ? "chevUp" : "chevDown"} size={12} />
      </button>
      {pos && ReactDOM.createPortal(
        <div ref={dropdownRef} className="chip-pop chip-pop--searchable"
          style={{ position: "fixed", top: pos.top, left: pos.left, minWidth: pos.minW, maxHeight: pos.maxH, zIndex: 200 }}>
          {showSearch && (
            <div className="chip-pop__search">
              <Field ref={searchRef} leading="search" placeholder={searchPlaceholder}
                value={q} onChange={e => setQ(e.target.value)} />
            </div>
          )}
          <div className="chip-pop__list">
            {renderItems()}
            {filtered.length === 0 && <div className="chip-pop__empty">無符合選項</div>}
          </div>
          {multi && (
            <div className="chip-pop__foot">
              <span>已選 <b>{selected.length}</b> 項</span>
              <div className="chip-pop__foot-actions">
                {!q.trim() && (
                  <button type="button" className="chip-pop__clear"
                    onClick={() => onChange(normOpts.map(o => o.value))}>
                    {normOpts.every(o => selected.includes(o.value)) ? "已全選" : "全選"}
                  </button>
                )}
                <button type="button" className="chip-pop__clear"
                  disabled={selected.length === 0} onClick={() => onChange([])}>清除</button>
              </div>
            </div>
          )}
        </div>,
        document.body
      )}
    </>
  );
};

/* ---------- Pager ---------- */
// 分頁列。unit 為資料單位文字（e.g. "張建立單"）。
// 用法：<Pager total={n} page={p} pageSize={s} onPageChange={setPage} onSizeChange={fn} unit="筆" />
const Pager = ({ total, page, pageSize, onPageChange, onSizeChange, unit = "筆", pageSizes }) => {
  const sizes = pageSizes || (typeof PAGE_SIZES !== "undefined" ? PAGE_SIZES : [10, 20, 50]);
  const totalPages = Math.max(1, Math.ceil(total / pageSize));
  const pages = Array.from({ length: totalPages }, (_, i) => i + 1)
    .filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1)
    .reduce((acc, p, idx, arr) => {
      if (idx > 0 && p - arr[idx - 1] > 1) acc.push("…");
      acc.push(p);
      return acc;
    }, []);

  return (
    <div className="pg">
      <span className="info">共 <b style={{ color: "var(--fg-1)" }}>{total}</b> {unit}</span>
      <button className="arr" onClick={() => onPageChange(Math.max(1, page - 1))} disabled={page === 1}>
        <Icon name="chevLeft" size={12} />
      </button>
      {pages.map((p, i) =>
        p === "…"
          ? <span key={"e" + i} style={{ padding: "0 2px", color: "var(--fg-3)", fontSize: 13 }}>…</span>
          : <button key={p} className={"num" + (p === page ? " active" : "")} onClick={() => onPageChange(p)}>{p}</button>
      )}
      <button className="arr" onClick={() => onPageChange(Math.min(totalPages, page + 1))} disabled={page === totalPages}>
        <Icon name="chevRight" size={12} />
      </button>
      <select className="field" value={pageSize}
        onChange={e => { onSizeChange(+e.target.value); }}>
        {sizes.map(n => <option key={n} value={n}>{n} / 頁</option>)}
      </select>
    </div>
  );
};

/* ---------- DateRangeChip ---------- */
// 日期區間篩選 chip，點擊展開 popover，填完開始/結束日期後點「套用」觸發篩選
// from / to 格式為 "YYYY-MM-DD"（來自 <input type="date">）或 ""
// 用法：<DateRangeChip label="建立日期" from={from} to={to} onApply={(f, t) => { … }} />
const DateRangeChip = ({ label, from, to, onApply }) => {
  const [open, setOpen] = useState(false);
  const [draftFrom, setDraftFrom] = useState(from || "");
  const [draftTo, setDraftTo] = useState(to || "");

  const hasValue = !!(from || to);

  const fmt = (d) => {
    if (!d) return "";
    const [, m, day] = d.split("-");
    return `${m}/${day}`;
  };
  const displayText = () => {
    if (from && to) return `${fmt(from)} – ${fmt(to)}`;
    if (from) return `${fmt(from)} 起`;
    if (to) return `至 ${fmt(to)}`;
    return null;
  };

  const handleToggle = () => {
    if (!open) {
      if (from || to) {
        setDraftFrom(from || "");
        setDraftTo(to || "");
      } else {
        const today = new Date();
        const todayStr = today.toISOString().slice(0, 10);
        const d30 = new Date(today);
        d30.setDate(d30.getDate() - 30);
        setDraftFrom(d30.toISOString().slice(0, 10));
        setDraftTo(todayStr);
      }
    }
    setOpen(o => !o);
  };
  const handleClose = () => setOpen(false);
  const rangeInvalid = !!(draftFrom && draftTo && draftFrom > draftTo);
  const handleApply = () => { if (rangeInvalid) return; onApply(draftFrom, draftTo); setOpen(false); };
  const handlePopClear = () => { setDraftFrom(""); setDraftTo(""); };
  const handleChipClear = (e) => { e.stopPropagation(); onApply("", ""); };

  return (
    <div className="chip-wrap">
      <button
        type="button"
        className={"chip" + (hasValue ? " chip--active" : "")}
        onClick={handleToggle}
      >
        <span className="chip__lbl">{label}</span>
        {hasValue && <span className="chip__val">{displayText()}</span>}
        {hasValue
          ? <span onClick={handleChipClear} className="chip__clear"><Icon name="close" size={12} /></span>
          : <Icon name="chevDown" size={12} />}
      </button>
      {open && (
        <>
          <div className="chip-mask" onClick={handleClose} />
          <div className="chip-pop chip-pop--daterange">
            <div className="drp-title">{label}</div>
            <div className="drp-row">
              <label className="drp-label">開始日期</label>
              <input type="date" className="drp-input" value={draftFrom}
                max={draftTo || undefined}
                onChange={e => setDraftFrom(e.target.value)} />
            </div>
            <div className="drp-row">
              <label className="drp-label">結束日期</label>
              <input type="date" className="drp-input" value={draftTo}
                min={draftFrom || undefined}
                onChange={e => setDraftTo(e.target.value)} />
            </div>
            {rangeInvalid && <div className="drp-error">開始日期不能晚於結束日期</div>}
            <div className="chip-pop__foot drp-foot">
              <button type="button" className="chip-pop__clear" onClick={handlePopClear}>清除</button>
              <Button variant="primary" size="sm" onClick={handleApply} disabled={rangeInvalid}>套用</Button>
            </div>
          </div>
        </>
      )}
    </div>
  );
};

/* ---------- ModeToggle ---------- */
// 精選欄位 / 全欄位切換。全欄位文案固定為「全欄位」；compactLabel 預設「精選欄位」，各模組無需覆寫。
// 用法：<ModeToggle mode={mode} onChange={setMode} />
const ModeToggle = ({ mode, onChange, compactLabel = "精選欄位" }) => (
  <div className="seg-ctrl">
    <button
      className={`seg-ctrl__btn${mode === "compact" ? " seg-ctrl__btn--active" : ""}`}
      onClick={() => onChange("compact")}>{compactLabel}</button>
    <button
      className={`seg-ctrl__btn${mode === "full" ? " seg-ctrl__btn--active" : ""}`}
      onClick={() => onChange("full")}>全欄位</button>
  </div>
);

/* ---------- FilterDrawer ---------- */
// 進階篩選 Drawer — 全域可複用。容器負責 overlay / 動畫 / header / footer；filter 欄位由各模組以 children 傳入。
// FilterGroup:   分組標題 + body 容器
// FilterTagStrip:套用後的 tag 摘要條（放在 table 上方）
// FilterDrawer:  整個 drawer 容器
//
// 用法：
//   <FilterTagStrip tags={activeTags} onClearAll={handleClearAll} />
//   <FilterDrawer open={open} activeCount={N} onClose={…} onClear={…} onApply={…}>
//     <FilterGroup label="商業資訊">…欄位…</FilterGroup>
//   </FilterDrawer>
//
// activeTags 格式：[{ label: "毛利率 ≥ 25%", onRemove: fn }, …]

const FilterGroup = ({ label, children }) => (
  <div className="fdr-group">
    <div className="fdr-group__label">{label}</div>
    <div className="fdr-group__body">{children}</div>
  </div>
);

const FilterTagStrip = ({ tags, onClearAll }) => {
  if (!tags || tags.length === 0) return null;
  return (
    <div className="fdr-strip">
      <span className="fdr-strip__prefix">進階篩選：</span>
      {tags.map((t, i) => (
        <span key={i} className="fdr-strip__tag">
          {t.label}
          <span className="fdr-strip__remove" onClick={t.onRemove}>×</span>
        </span>
      ))}
      <button type="button" className="fdr-strip__clear" onClick={onClearAll}>清除全部</button>
    </div>
  );
};

const FilterDrawer = ({ open, title = "進階篩選", activeCount = 0, onClose, onClear, onApply, children }) => (
  <>
    {open && <div className="fdr-overlay" onClick={onClose} />}
    <div className={"fdr-drawer" + (open ? " fdr-drawer--open" : "")}>
      <div className="fdr-drawer__head">
        <span className="fdr-drawer__title">{title}</span>
        <button type="button" className="fdr-drawer__close" onClick={onClose}>
          <Icon name="close" size={16} />
        </button>
      </div>
      <div className="fdr-drawer__body">{children}</div>
      <div className="fdr-drawer__foot">
        <button type="button" className="fdr-drawer__clear" onClick={onClear}>清除全部</button>
        <Button variant="primary" size="sm" onClick={onApply}>套用篩選</Button>
      </div>
    </div>
  </>
);

// 工具列「進階篩選」觸發按鈕（各模組自行放置，保持一致樣式）
// <AdvFilterBtn activeCount={N} onClick={…} />
const AdvFilterBtn = ({ activeCount = 0, onClick }) => (
  <button type="button" className={"btn-adv" + (activeCount > 0 ? " btn-adv--active" : "")} onClick={onClick}>
    <Icon name="sliders" size={12} />
    進階篩選
    {activeCount > 0 && <span className="btn-adv__count">{activeCount}</span>}
  </button>
);

/* ---------- FdrChipGroup ---------- */
// FilterDrawer 內的 chip 篩選欄位，支援多選（預設）與單選。
// 多選模式：顯示「已選 N 項」計數、「全選｜清除」controls；options > maxVisible 時顯示「顯示更多」。
// 單選模式：無 controls，無計數，無 show-more。
//
// 用法：
//   <FdrChipGroup label="主類別" options={opts} selected={arr} onChange={newArr => setAdv("mainCat")(newArr)} />
//   <FdrChipGroup label="配貨方式" options={["日配","統配"]} selected={str} multiSelect={false} onChange={v => setAdv("deliv")(v)} />
const FdrChipGroup = ({ label, options = [], selected, multiSelect = true, maxVisible = 10, onChange, noDataMsg }) => {
  const [expanded, setExpanded] = useState(false);
  const arr = multiSelect ? (selected || []) : null;
  const isOn = v => multiSelect ? arr.includes(v) : selected === v;
  const selectedCount = multiSelect ? arr.length : 0;
  const hasMore = options.length > maxVisible;
  const visible = (hasMore && !expanded) ? options.slice(0, maxVisible) : options;

  const handleChip = v => {
    if (multiSelect) {
      onChange(arr.includes(v) ? arr.filter(x => x !== v) : [...arr, v]);
    } else {
      onChange(selected === v ? "" : v);
    }
  };

  if (options.length === 0) return (
    <div className="fdr-row">
      <label className="fdr-label">{label}</label>
      <span style={{ color: "var(--fg-3)", fontSize: 12 }}>{noDataMsg || "目前無可篩選資料"}</span>
    </div>
  );

  return (
    <div className="fdr-row">
      {multiSelect ? (
        <div className="fdr-chip-header">
          <span className="fdr-label">{label}</span>
          {selectedCount > 0 && <span className="fdr-chip-selected">已選 {selectedCount} 項</span>}
        </div>
      ) : (
        <label className="fdr-label">{label}</label>
      )}
      {multiSelect && (
        <div className="fdr-chip-controls">
          <span onClick={() => onChange([...options])}>全選</span>
          <span className="fdr-chip-sep">｜</span>
          <span onClick={() => onChange([])}>清除</span>
        </div>
      )}
      <div className="fdr-chips">
        {visible.map(v => (
          <span key={v} className={"fdr-chip" + (isOn(v) ? " on" : "")} onClick={() => handleChip(v)}>{v}</span>
        ))}
      </div>
      {hasMore && (
        <button type="button" className="fdr-show-more" onClick={() => setExpanded(e => !e)}>
          <Icon name={expanded ? "chevUp" : "chevDown"} size={12} />
          {expanded ? "顯示更少" : `顯示更多（還有 ${options.length - maxVisible} 項）`}
        </button>
      )}
    </div>
  );
};

/* ─── CellDisplay ─────────────────────────────────────────────────────────
   Renders the read (non-editing) state of a table cell for all field types.
   Props:
     field        — field definition object from FIELDS
     value        — current value of field.key on the row
     row          — full row object (needed for cascadeSelect, dateRange, showWhen)
     errMsg       — validation error string (for dateRange colour hint)
     onDirectPatch— (val) => void; only passed for radio cells in editable mode
   ────────────────────────────────────────────────────────────────────────── */
const CellDisplay = ({ field, value, row, errMsg, onDirectPatch }) => {
  const fmtNum = v => { const n = parseFloat(v); return isNaN(n) ? v : n.toLocaleString("zh-TW"); };
  const fmtDate = d => {
    if (!d) return "";
    const p = d.split("-");
    return p.length === 3 ? `${p[0]}/${p[1]}/${p[2]}` : d;
  };

  if (field.type === "bool") {
    if (value === true || value === "true") return <span className="cell-bool cell-bool--yes">✓ 是</span>;
    if (field.formula)                      return <span className="cell-bool cell-bool--no">✗ 否</span>;
    return <span className="cell-bool cell-bool--em">—</span>;
  }
  if (field.formula) {
    const display = field.unit === "NT$" ? (value ? `NT$${fmtNum(value)}` : "") : String(value ?? "");
    return <span className="cell-formula-val">{display || <span className="cell-em">—</span>}</span>;
  }
  if (field.type === "cascadeSelect") {
    const parent = row ? (row[field.key] || "") : "";
    const child  = (row && field.childKey) ? (row[field.childKey] || "") : "";
    if (!parent && !child) return <span className="cell-em">—</span>;
    return (
      <span className="cv-cascade">
        <span className="cv-cascade__seg">{parent || "—"}</span>
        {child && <><span className="cv-cascade__sep"> / </span><span className="cv-cascade__seg">{child}</span></>}
      </span>
    );
  }
  if (field.type === "radio") {
    const opts = (typeof SOURCES !== "undefined" && SOURCES[field.source]) || [];
    return (
      <div className="cv-radio" onMouseDown={e => e.stopPropagation()} onClick={e => e.stopPropagation()}>
        {opts.map(opt => (
          <div key={opt}
            className={`radio-opt${value === opt ? " selected" : ""}`}
            style={{ cursor: onDirectPatch ? "pointer" : "default" }}
            onClick={onDirectPatch ? () => onDirectPatch(opt) : undefined}>
            <div className="radio-dot"><div className="radio-dot-inner" /></div>
            <span>{opt}</span>
          </div>
        ))}
      </div>
    );
  }
  if (field.type === "multiSelect") {
    const vals = Array.isArray(value) ? value : [];
    if (!vals.length) return <span className="cell-em">—</span>;
    const extra = vals.length - 2;
    return (
      <div className="cv-chips">
        {vals.slice(0, 2).map((v, i) => <span key={i} className="chip-tag">{v}</span>)}
        {extra > 0 && <span className="chip-tag chip-tag--more">+{extra}</span>}
      </div>
    );
  }
  if (field.type === "tags") {
    const vals = Array.isArray(value) ? value : (value ? [String(value)] : []);
    if (!vals.length) return <span className="cell-em">—</span>;
    const extra = vals.length - 2;
    return (
      <div className="cv-tags">
        {vals.slice(0, 2).map((v, i) => <span key={i} className="tag-item">{v}</span>)}
        {extra > 0 && <span className="tag-item tag-item--more">+{extra}</span>}
      </div>
    );
  }
  if (field.type === "url") {
    if (!value) return <span className="cell-em">—</span>;
    const str = String(value);
    const valid = str.startsWith("https://");
    return (
      <div className={`cv-url${valid ? "" : " cv-url--invalid"}`}>
        {valid
          ? <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
          : <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>}
        <span>{str}</span>
      </div>
    );
  }
  if (field.type === "dateRange") {
    const start = row ? (row[field.key] || "") : "";
    const end   = (row && field.endKey) ? (row[field.endKey] || "") : "";
    if (!start && !end) return <span className="cell-em">—</span>;
    return (
      <div className="cv-drange">
        <span className="drange-val">{start ? fmtDate(start) : "—"}</span>
        <span className="drange-sep">→</span>
        <span className="drange-val" style={errMsg ? { color: "var(--danger)" } : {}}>{end ? fmtDate(end) : "—"}</span>
      </div>
    );
  }

  const isEmpty = value === "" || value == null;
  if (isEmpty) return <span className="cell-em">—</span>;
  if (field.unit === "NT$") return <span className="cell-nt">{`NT$${fmtNum(value)}`}</span>;
  return <span className="cv-text">{String(value)}</span>;
};

/* ─── CellEditor sub-components ────────────────────────────────────────────
   Each type is its own component to avoid conditional hook ordering issues.
   ────────────────────────────────────────────────────────────────────────── */

const _CeBase = ({ field, value, row, onCommit, onCancel, alwaysOn }) => {
  const [draft, setDraft] = useState(value ?? "");
  const ref = useRef(null);
  useEffect(() => {
    if (alwaysOn) return;
    if (ref.current) {
      ref.current.focus();
      if (ref.current.select) ref.current.select();
      if (ref.current.classList && ref.current.classList.contains("cell-editor-autogrow")) {
        ref.current.style.height = "auto";
        const sh = ref.current.scrollHeight;
        ref.current.style.height = sh + "px";
        requestAnimationFrame(() => {
          if (!ref.current) return;
          const td = ref.current.closest("td");
          if (td && td.clientHeight > sh) ref.current.style.height = td.clientHeight + "px";
        });
      }
    }
  }, []);
  const handleKeyDown = e => {
    if (e.key === "Escape") { e.preventDefault(); onCancel(); return; }
    if (e.key === "Tab") { e.preventDefault(); onCommit(draft, e.shiftKey ? "left" : "right"); return; }
    if (e.key === "Enter") {
      if (field.type === "text") return;
      e.preventDefault(); onCommit(draft, "down"); return;
    }
    if ((e.key === "ArrowUp" || e.key === "ArrowDown") && field.type !== "select") {
      e.preventDefault(); onCommit(draft, e.key === "ArrowUp" ? "up" : "down");
    }
  };
  if (field.type === "select") {
    const opts = field.parentKey
      ? ((typeof CATEGORY_TREE !== "undefined" && row && CATEGORY_TREE[row[field.parentKey]]) || [])
      : ((typeof SOURCES !== "undefined" && SOURCES[field.source]) || []);
    return (
      <PortalSelect
        value={draft}
        options={opts}
        multi={false}
        onChange={val => { setDraft(val); onCommit(val, "blur"); }}
        placeholder="—"
        triggerClassName="cell-editor-portal-trigger"
        searchPlaceholder={field.label ? `搜尋${field.label}…` : "搜尋…"}
      />
    );
  }
  if (field.type === "str") {
    return (
      <input ref={ref} type="text" className="cell-editor-input" value={draft}
        placeholder={field.placeholder || ""}
        onChange={e => setDraft(e.target.value)} onKeyDown={handleKeyDown}
        onBlur={() => onCommit(draft, "blur")} />
    );
  }
  if (field.type === "text") {
    const expandHeight = el => {
      el.style.height = "auto";
      const sh = el.scrollHeight;
      el.style.height = sh + "px";
      requestAnimationFrame(() => {
        const td = el.closest("td");
        if (td && td.clientHeight > sh) el.style.height = td.clientHeight + "px";
      });
    };
    return (
      <textarea ref={ref} className="cell-editor-autogrow" value={draft} rows={1}
        placeholder={field.placeholder || ""}
        onFocus={e => expandHeight(e.target)}
        onChange={e => { setDraft(e.target.value); expandHeight(e.target); }}
        onKeyDown={handleKeyDown}
        onBlur={e => { e.target.style.height = ""; onCommit(draft, "blur"); }} />
    );
  }
  const inputType = field.type === "num" ? "number" : "date";
  return (
    <input ref={ref} type={inputType} className="cell-editor-input" value={draft}
      onChange={e => setDraft(e.target.value)} onKeyDown={handleKeyDown} onBlur={() => onCommit(draft, "blur")} />
  );
};

const _CeRadio = ({ field, value, onCommit }) => {
  const opts = (typeof SOURCES !== "undefined" && SOURCES[field.source]) || [];
  return (
    <div className="cv-radio" onClick={e => e.stopPropagation()}>
      {opts.map(opt => (
        <div key={opt} className={`radio-opt${value === opt ? " selected" : ""}`}
          style={{ cursor: "pointer" }} onClick={() => onCommit(opt)}>
          <div className="radio-dot"><div className="radio-dot-inner" /></div>
          <span>{opt}</span>
        </div>
      ))}
    </div>
  );
};

const _CeCascade = ({ field, row, onCommit, onCancel }) => {
  const [pVal, setPVal] = useState(row ? (row[field.key] || "") : "");
  const [cVal, setCVal] = useState((row && field.childKey) ? (row[field.childKey] || "") : "");
  const parentOpts = (typeof SOURCES !== "undefined" && SOURCES[field.source]) || [];
  const childOpts  = (typeof CATEGORY_TREE !== "undefined" && CATEGORY_TREE[pVal]) || [];
  return (
    <div className="cell-editor-pop" onClick={e => e.stopPropagation()}>
      <div className="cell-editor-pop__row">
        <span className="cell-editor-pop__label">主類別</span>
        <select className="cell-editor-select-inline" value={pVal}
          onChange={e => { setPVal(e.target.value); setCVal(""); }}>
          <option value="">—</option>
          {parentOpts.map(o => <option key={o} value={o}>{o}</option>)}
        </select>
      </div>
      <div className="cell-editor-pop__row">
        <span className="cell-editor-pop__label">次類別</span>
        <select className="cell-editor-select-inline" value={cVal} disabled={!pVal}
          onChange={e => setCVal(e.target.value)}>
          <option value="">{pVal ? "—" : "請先選主類別"}</option>
          {childOpts.map(o => <option key={o} value={o}>{o}</option>)}
        </select>
      </div>
      <div className="cell-editor-pop__actions">
        <button className="btn btn--sm" onClick={onCancel}>取消</button>
        <button className="btn btn--sm btn--primary"
          onClick={() => onCommit({ [field.key]: pVal, [field.childKey]: cVal })}>確認</button>
      </div>
    </div>
  );
};

const _CeMultiSelect = ({ field, value, onCommit, onCancel }) => {
  const opts = (typeof SOURCES !== "undefined" && SOURCES[field.source]) || [];
  const [selected, setSelected] = useState(Array.isArray(value) ? [...value] : []);
  const [q, setQ] = useState("");
  const showSearch = opts.length >= 6;
  const filtered = q ? opts.filter(o => o.toLowerCase().includes(q.toLowerCase())) : opts;
  const toggle = opt => setSelected(p => p.includes(opt) ? p.filter(v => v !== opt) : [...p, opt]);
  const allSel = filtered.length > 0 && filtered.every(o => selected.includes(o));
  return (
    <div className="cell-editor-pop" onClick={e => e.stopPropagation()}>
      {showSearch && (
        <input className="cell-editor-input-search" autoFocus placeholder="搜尋…"
          value={q} onChange={e => setQ(e.target.value)} />
      )}
      <div className="cell-editor-ms-list">
        {filtered.map(opt => (
          <div key={opt} className={`cell-editor-ms-item${selected.includes(opt) ? " checked" : ""}`}
            onClick={() => toggle(opt)}>
            <div className="cell-editor-ms-check">
              {selected.includes(opt) && (
                <svg width="10" height="8" viewBox="0 0 10 8" fill="none">
                  <path d="M1 4L3.8 7 9 1" stroke="#fff" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
                </svg>
              )}
            </div>
            {opt}
          </div>
        ))}
      </div>
      <div className="cell-editor-ms-footer">
        <span>已選 {selected.length} 項</span>
        <div style={{ display: "flex", gap: 4 }}>
          {!q && <button className="btn btn--sm" onClick={() => setSelected(allSel ? [] : [...opts])}>{allSel ? "已全選" : "全選"}</button>}
          <button className="btn btn--sm" onClick={() => setSelected([])}>清除</button>
        </div>
      </div>
      <div className="cell-editor-pop__actions">
        <button className="btn btn--sm" onClick={onCancel}>取消</button>
        <button className="btn btn--sm btn--primary" onClick={() => onCommit(selected)}>確認</button>
      </div>
    </div>
  );
};

const _CeUrl = ({ value, onCommit, onCancel }) => {
  const [draft, setDraft] = useState(value || "");
  const [urlErr, setUrlErr] = useState("");
  const ref = useRef(null);
  useEffect(() => { if (ref.current) ref.current.focus(); }, []);
  const validate = v => (v && !v.startsWith("https://")) ? "格式應為 https:// 開頭的網址" : "";
  const tryCommit = (dir) => {
    const err = validate(draft);
    if (err) { setUrlErr(err); return; }
    onCommit(draft, dir);
  };
  return (
    <>
      <input ref={ref} className="cell-editor-input" type="text" value={draft}
        placeholder="https://…"
        onChange={e => { setDraft(e.target.value); setUrlErr(""); }}
        onBlur={() => { const err = validate(draft); setUrlErr(err); if (!err) onCommit(draft); }}
        onKeyDown={e => {
          if (e.key === "Escape") { e.preventDefault(); onCancel(); return; }
          if (e.key === "Enter") { e.preventDefault(); tryCommit("down"); return; }
          if (e.key === "Tab")  { e.preventDefault(); tryCommit(e.shiftKey ? "left" : "right"); }
        }} />
      {urlErr && <div className="cell-err-msg">{urlErr}</div>}
    </>
  );
};

const _CeTags = ({ value, onCommit, onCancel }) => {
  const init = Array.isArray(value) ? [...value] : (value ? [String(value)] : []);
  const [tags, setTags] = useState(init);
  const [input, setInput] = useState("");
  const ref = useRef(null);
  useEffect(() => { if (ref.current) ref.current.focus(); }, []);
  const addTag = () => {
    const v = input.trim();
    if (v && !tags.includes(v)) setTags(p => [...p, v]);
    setInput("");
  };
  return (
    <div className="cell-editor-pop" onClick={e => e.stopPropagation()}>
      <div className="cell-editor-tags-wrap">
        {tags.map((t, i) => (
          <span key={i} className="cell-editor-tag">
            {t}<span className="cell-editor-tag-x" onClick={() => setTags(p => p.filter((_, j) => j !== i))}>×</span>
          </span>
        ))}
        <input ref={ref} className="cell-editor-tags-input" value={input}
          placeholder="輸入後按 Enter 新增…"
          onChange={e => setInput(e.target.value)}
          onKeyDown={e => {
            if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); addTag(); }
            if (e.key === "Escape") { e.preventDefault(); onCancel(); }
          }} />
      </div>
      <div className="cell-editor-pop__hint">每個值按 Enter 確認，點 × 刪除</div>
      <div className="cell-editor-pop__actions">
        <button className="btn btn--sm" onClick={onCancel}>取消</button>
        <button className="btn btn--sm btn--primary" onClick={() => onCommit(tags)}>確認</button>
      </div>
    </div>
  );
};

const _CeDateRange = ({ field, row, onCommit, onCancel }) => {
  const [startVal, setStartVal] = useState(row ? (row[field.key] || "") : "");
  const [endVal,   setEndVal]   = useState((row && field.endKey) ? (row[field.endKey] || "") : "");
  const rangeErr = !!(startVal && endVal && startVal > endVal);
  return (
    <div className="cell-editor-pop" onClick={e => e.stopPropagation()}>
      <div className="cell-editor-pop__row">
        <span className="cell-editor-pop__label">開始日</span>
        <input type="date" className="cell-editor-date" value={startVal}
          max={endVal || undefined} onChange={e => setStartVal(e.target.value)} />
      </div>
      <div className="cell-editor-pop__row">
        <span className="cell-editor-pop__label">結束日</span>
        <input type="date" className="cell-editor-date" value={endVal}
          min={startVal || undefined} onChange={e => setEndVal(e.target.value)} />
      </div>
      {rangeErr && <div className="cell-err-msg" style={{ padding: "4px 0 2px" }}>結束日不可早於開始日</div>}
      <div className="cell-editor-pop__actions">
        <button className="btn btn--sm" onClick={onCancel}>取消</button>
        <button className="btn btn--sm btn--primary" disabled={rangeErr}
          onClick={() => !rangeErr && onCommit({ [field.key]: startVal, [field.endKey]: endVal })}>確認</button>
      </div>
    </div>
  );
};

/* bool toggle — commits immediately on mount, renders nothing visible */
const _CeBool = ({ field, row, value, onCommit, alwaysOn }) => {
  useEffect(() => {
    if (alwaysOn) return;
    const cur = row ? row[field.key] : false;
    onCommit(!(cur === true || cur === "true"));
  }, []);
  if (alwaysOn) {
    const checked = value === true || value === "true";
    return (
      <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
        <Check checked={checked} onChange={onCommit} />
        <span style={{ fontSize: 13, color: "var(--fg-2)" }}>{checked ? "是" : "否"}</span>
      </span>
    );
  }
  return null;
};

/* ─── ShowErrorsCtx — provides showErrors bool to all CellEditor descendants ─
   Usage: wrap the editable table in <ShowErrorsProvider value={showErrors}>.
   Each col's field object may define validate(row) => string|null.
   CellEditor reads the context + calls validate to decide error display.
   ────────────────────────────────────────────────────────────────────────── */
const ShowErrorsCtx = React.createContext(false);
const ShowErrorsProvider = ({ value, children }) => (
  <ShowErrorsCtx.Provider value={value}>{children}</ShowErrorsCtx.Provider>
);

/* useFormValidation — manages showErrors state + validates rows before proceeding.
   fields: array of { validate(row) => string|null } — same objects used by CellEditor/ErrWrap.
   rowHasError is derived from fields; no hand-written duplicate logic needed in modules.
   verb: "送出" | "送審" — appended to warning toast message.
   Returns { showErrors, submitGuard }; call submitGuard(onProceed) on submit buttons.
   Usage:
     const FIELDS = [{ validate: r => !r.foo ? "必填" : null }, ...];
     const { showErrors, submitGuard } = useFormValidation(rows, FIELDS, "送審");
     const handleSend = () => submitGuard(() => setPageMode("doubleCheck")); */
const useFormValidation = (rows, fields, verb = "送出") => {
  const [showErrors, setShowErrors] = useState(false);
  const { push: toast } = useToast();
  const triggerShowErrors = () => setShowErrors(true);
  const submitGuard = (onProceed) => {
    setShowErrors(true);
    const errCount = rows.filter(row => fields.some(f => f.validate?.(row))).length;
    if (errCount > 0) {
      toast({ kind: "warning", msg: `有 ${errCount} 筆品項資料需修正，請確認後再${verb}` });
    } else {
      onProceed();
    }
  };
  return { showErrors, submitGuard, triggerShowErrors };
};

/* Alert-circle SVG (circle with ! — less severe than triangle warning) */
const _ErrSvg = () => (
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
    strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: "block" }}>
    <circle cx="12" cy="12" r="10"/><path d="M12 8v5M12 16v.5"/>
  </svg>
);

/* RowErrTip — sysCheck row error indicator + hover tooltip.
   Render inside the first <td> that has position:relative or position:sticky.
   Draws a 4px red accent at the cell's left edge; hovering shows a dark bubble.
   Usage: {errMsg && <RowErrTip msg={errMsg} />} */
const RowErrTip = ({ msg }) => {
  const [tipPos, setTipPos] = useState(null);
  const ref = useRef(null);
  const show = () => {
    if (!ref.current) return;
    const r   = ref.current.getBoundingClientRect();
    const tr  = ref.current.closest("tr");
    const row = tr ? tr.getBoundingClientRect() : r;
    setTipPos({ top: row.top + row.height / 2, left: r.left + 12 + 6 });
  };
  return (
    <>
      <span ref={ref} className="row-err-ind" onMouseEnter={show} onMouseLeave={() => setTipPos(null)} />
      {tipPos && ReactDOM.createPortal(
        <div className="row-err-bubble" style={{ top: tipPos.top, left: tipPos.left }}>{msg}</div>,
        document.body
      )}
    </>
  );
};

/* Standalone error icon + portal tooltip.
   Use on non-input cells (e.g. formula FAIL indicators). */
const ErrIconTip = ({ msg }) => {
  const [tipPos, setTipPos] = useState(null);
  const ref = useRef(null);
  const show = () => {
    const r = ref.current.getBoundingClientRect();
    setTipPos({ top: r.bottom + 6, left: r.left + r.width / 2 });
  };
  return (
    <>
      <span ref={ref} className="err-icon-inline" onMouseEnter={show} onMouseLeave={() => setTipPos(null)}>
        <_ErrSvg />
      </span>
      {tipPos && ReactDOM.createPortal(
        <div className="err-tip-bubble" style={{ top: tipPos.top, left: tipPos.left }}>{msg}</div>,
        document.body
      )}
    </>
  );
};

/* Error wrapper for non-CellEditor inputs (e.g. PortalSelect, AdjTypeSelector).
   Icon is portaled to body — zero layout impact on the cell. */
const ErrWrap = ({ errMsg, children }) => {
  const [iconPos, setIconPos] = useState(null);
  const [showTip, setShowTip] = useState(false);
  const ref = useRef(null);

  useEffect(() => {
    if (!errMsg) { setIconPos(null); setShowTip(false); return; }
    const updatePos = () => {
      if (!ref.current) return;
      const r = ref.current.getBoundingClientRect();
      setIconPos({ top: r.top + r.height / 2 - 7, left: r.right + 4 });
    };
    updatePos();
    const scrollEl = ref.current?.closest('.tbl-wrap');
    scrollEl?.addEventListener('scroll', updatePos, { passive: true });
    window.addEventListener('resize', updatePos, { passive: true });
    return () => {
      scrollEl?.removeEventListener('scroll', updatePos);
      window.removeEventListener('resize', updatePos);
    };
  }, [errMsg]);

  return (
    <>
      <div ref={ref} className={errMsg ? "err-wrap" : undefined}>{children}</div>
      {errMsg && iconPos && ReactDOM.createPortal(
        <>
          <span
            className="err-icon-inline"
            style={{ position: 'fixed', top: iconPos.top, left: iconPos.left, zIndex: 9998 }}
            onMouseEnter={() => setShowTip(true)}
            onMouseLeave={() => setShowTip(false)}
          >
            <_ErrSvg />
          </span>
          {showTip && (
            <div className="err-tip-bubble" style={{ top: iconPos.top + 20, left: iconPos.left + 7 }}>
              {errMsg}
            </div>
          )}
        </>,
        document.body
      )}
    </>
  );
};

/* ─── CellEditor (dispatcher) ──────────────────────────────────────────────
   Props: field, value, row, onCommit(val, dir?), onCancel
   onCommit may receive a plain-object for cascadeSelect/dateRange (multi-key patch)
   or an Array for multiSelect/tags.
   field.validate(row) => string|null — optional; drives error display via ShowErrorsCtx.
   ────────────────────────────────────────────────────────────────────────── */
const CellEditor = ({ field, value, row, onCommit, onCancel, alwaysOn }) => {
  const showErrors = React.useContext(ShowErrorsCtx);
  const errMsg = showErrors ? (field.validate?.(row) ?? null) : null;
  const cancel = onCancel || (() => {});
  let inner;
  if (field.type === "bool")          inner = <_CeBool        field={field} row={row} value={value} onCommit={onCommit} alwaysOn={alwaysOn} />;
  else if (field.type === "radio")    inner = <_CeRadio       field={field} value={value} onCommit={onCommit} onCancel={cancel} />;
  else if (field.type === "cascadeSelect") inner = <_CeCascade field={field} row={row}    onCommit={onCommit} onCancel={cancel} />;
  else if (field.type === "multiSelect")   inner = <_CeMultiSelect field={field} value={value} onCommit={onCommit} onCancel={cancel} />;
  else if (field.type === "url")      inner = <_CeUrl         value={value}               onCommit={onCommit} onCancel={cancel} />;
  else if (field.type === "tags")     inner = <_CeTags        value={value}               onCommit={onCommit} onCancel={cancel} />;
  else if (field.type === "dateRange") inner = <_CeDateRange  field={field} row={row}     onCommit={onCommit} onCancel={cancel} />;
  else                                inner = <_CeBase field={field} value={value} row={row} onCommit={onCommit} onCancel={cancel} alwaysOn={alwaysOn} />;
  return <ErrWrap errMsg={errMsg}>{inner}</ErrWrap>;
};

/* ---------- MetaCard ---------- */
// Shared collapsible info card for L2 pages.
// Collapsed: shows summary text. Expanded: shows sectionTitle + dl body.
// Usage: <MetaCard summary="…" sectionTitle="批次資訊"><div className="meta-card__row">…</div></MetaCard>
const MetaCard = ({ summary, sectionTitle, children }) => {
  const [open, setOpen] = useState(false);
  return (
    <div className={`section-card${open ? " section-card--open" : ""}`}>
      <button className="section-card__toggle" onClick={() => setOpen(v => !v)} aria-expanded={open}>
        <svg className="section-card__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none"
          stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
          <polyline points="9 18 15 12 9 6" />
        </svg>
        <span className="section-card__title">{open ? sectionTitle : summary}</span>
      </button>
      {open && <dl className="meta-card__body">{children}</dl>}
    </div>
  );
};

/* ---------- SubmitConfirmPage ---------- */
// Reusable page shell for any module's 送審前確認 view.
// children = the comparison table card.
const SubmitConfirmPage = ({ moduleName, batchName, subtitle, onBack, onConfirm, confirmDisabled, confirmLabel = "確認送審", pageTitle = "送審前確認", children }) => (
  <>
    <div className="page__head">
      <div>
        <div className="crumb">
          <a onClick={onBack}>{moduleName}</a><span className="sep">/</span>
          <a onClick={onBack}>{batchName}</a><span className="sep">/</span>
          <span className="cur">{pageTitle}</span>
        </div>
        <div className="page__title">{pageTitle}</div>
        {subtitle && <div className="page__subtitle">{subtitle}</div>}
      </div>
      <div className="page__actions">
        <Button variant="secondary" onClick={onBack}>返回修改</Button>
        <Button variant="primary" icon="send" disabled={confirmDisabled} onClick={onConfirm}>{confirmLabel}</Button>
      </div>
    </div>
    {children}
  </>
);

/* ---------- TableGroupHeader ---------- */
// Generic group-band header row for .tbl tables.
// cols: [{ key, group?, ... }]  groupLabels: { A: "label", ... }
// No-group cols produce unstyled empty cells; group cols get th-{a-f} color class.
const TableGroupHeader = ({ cols, groupLabels = {}, leadingCount = 0, leadingSlot = null, actionCol = null, filler = false }) => {
  const groups = [];
  let cur = null;
  cols.forEach(c => {
    const gKey = c.group || "_none_" + c.key;
    if (!cur || cur.key !== gKey) {
      cur = { key: gKey, label: groupLabels[c.group] || "", span: 1, group: c.group };
      groups.push(cur);
    } else {
      cur.span++;
    }
  });
  const lastGroup = groups[groups.length - 1];
  return (
    <tr className="tbl__band-row">
      {leadingSlot}
      {Array.from({ length: leadingCount }, (_, i) => <th key={`_lead_${i}`} />)}
      {groups.map(g => (
        <th key={g.key} colSpan={g.span}
          className={g.group ? `th-${g.group.toLowerCase()}` : ""}>
          {g.label}
        </th>
      ))}
      {actionCol && (
        <th rowSpan={2} style={{ width: actionCol.w, minWidth: actionCol.w }}>{actionCol.label}</th>
      )}
      {filler && (
        <th key="__filler"
          className={lastGroup?.group ? `th-${lastGroup.group.toLowerCase()}` : ""} />
      )}
    </tr>
  );
};

/* ---------- RowActions ---------- */
// 統一 list 表格功能欄按鈕。actions = [{ label, icon?, danger?, onClick? }]
const RowActions = ({ actions }) => (
  <span className="actions">
    {actions.map((a, i) => (
      <button key={i} className={"row-act" + (a.danger ? " row-act--danger" : "")} onClick={a.onClick}>
        {a.label}
      </button>
    ))}
  </span>
);

/* ---------- DeleteRowBtn ---------- */
// Shared delete icon button for the 操作 column in any module's row table.
const DeleteRowBtn = ({ onClick }) => (
  <button className="btn-icon btn-icon--sm" onClick={onClick} title="刪除">
    <Icon name="trash" size={16} />
  </button>
);

/* ---------- InfoNote ---------- */
// 資訊提示條。variant="info"（預設，藍）或 "warning"（琥珀）。icon 依 variant 自動切換（info→circle, warning→triangle）。
// Usage: <InfoNote>說明文字</InfoNote>  or  <InfoNote variant="warning">…</InfoNote>
// children 接受 ReactNode（含多行 <div> 結構）。
// className：轉發至根元素（如在多欄 grid 中傳 "form-grid__full" 跨整列）。
const InfoNote = ({ children, style, variant = "info", className = "", ...rest }) => (
  <div className={"info-note" + (variant !== "info" ? " info-note--" + variant : "") + (className ? " " + className : "")} style={style} {...rest}>
    <Icon name={variant === "warning" ? "warn" : "info"} size={16} style={{ flex: "none" }} />
    <div>{children}</div>
  </div>
);

/* ---------- SysCheckBanner ---------- */
// 系統檢查 phase 的通過 / 失敗狀態 banner。三個 adj 模組共用。
// Usage: <SysCheckBanner allPass={bool} errorCount={n} />
const SysCheckBanner = ({ allPass, errorCount, actionLabel = "送審" }) => (
  <div className={"syschk-banner syschk-banner--" + (allPass ? "ok" : "error")}>
    <Icon name={allPass ? "check" : "alertCircle"} size={16} />
    {allPass
      ? `全部品項通過系統檢查，可確認${actionLabel}。`
      : `發現 ${errorCount} 筆系統檢查問題，請修正後重新${actionLabel}。`}
  </div>
);

/* ---------- EmptyState ---------- */
// 空資料狀態。放在 .card 內、table 外。
// Usage: <EmptyState icon="box" title="尚無品項" desc="說明文字"><Button>...</Button></EmptyState>
const EmptyState = ({ icon = "box", title, desc, children, style }) => (
  <div className="empty" style={style}>
    <div className="empty__icon"><Icon name={icon} size={56} /></div>
    {title && <div className="empty__title">{title}</div>}
    {desc  && <div className="empty__desc">{desc}</div>}
    {children && (
      <div style={{ display: "inline-flex", gap: 8, marginTop: 12 }}>
        {children}
      </div>
    )}
  </div>
);

/* ---------- SkuToolbar ---------- */
// SKU 表格工具列容器。CSS（border-bottom / flex）定義在 ui-kit.css，禁止各模組自行定義 toolbar border。
// Usage: <SkuToolbar><span>共 <b>N</b> 筆</span><Button style={{marginLeft:"auto"}}>...</Button></SkuToolbar>
const SkuToolbar = ({ children }) => (
  <div className="sku-toolbar">{children}</div>
);

/* ---------- InfoTip ---------- */
// Column-header label + info icon + floating bubble.
// Hover anywhere on the <th> to show. Label+icon are inline (same flow).
// A transparent position:absolute overlay captures the full-th hover zone.
// Bubble escapes overflow via portal to document.body.
// Usage: <InfoTip label="欄位名" text="說明文字" />
const InfoTip = ({ label, text }) => {
  const [tipPos, setTipPos] = useState(null);
  const overlayRef = useRef(null);
  const show = () => {
    const th = overlayRef.current.closest("th");
    const rect = th.getBoundingClientRect();
    setTipPos({ top: rect.bottom + 6, left: rect.left + rect.width / 2 });
  };
  const hide = () => setTipPos(null);
  return (
    <>
      <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
        {label}
        <Icon name="info" size={12} style={{ flex: "none", color: "var(--fg-4)" }} />
      </span>
      <span ref={overlayRef} className="th-tip-overlay" onMouseEnter={show} onMouseLeave={hide} />
      {tipPos && ReactDOM.createPortal(
        <div className="info-tip-bubble" style={{ top: tipPos.top, left: tipPos.left }}>
          {text}
        </div>,
        document.body
      )}
    </>
  );
};

/* ---------- TruncCell ---------- */
// Truncated text display with immediate hover tooltip (no OS delay).
// Renders a block span with ellipsis; tooltip only shows when content is actually clipped.
// Escapes overflow:hidden via ReactDOM.createPortal. Usage inside <td>:
//   <TruncCell text={value} />  or  <TruncCell text={value} style={{ color: "var(--danger)" }} />
const TruncCell = ({ text, style, className }) => {
  const [tipPos, setTipPos] = useState(null);
  const ref = useRef(null);

  const handleMouseEnter = () => {
    const el = ref.current;
    if (!el || el.scrollWidth <= el.clientWidth) return;
    const rect = el.getBoundingClientRect();
    setTipPos({ top: rect.bottom + 4, left: rect.left });
  };

  return (
    <>
      <span
        ref={ref}
        className={className}
        style={{ display: "block", overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis", ...style }}
        onMouseEnter={handleMouseEnter}
        onMouseLeave={() => setTipPos(null)}
      >
        {text || <span style={{ color: "var(--fg-4)" }}>—</span>}
      </span>
      {tipPos && ReactDOM.createPortal(
        <div className="trunc-tooltip" style={{ top: tipPos.top, left: tipPos.left }}>{text}</div>,
        document.body
      )}
    </>
  );
};

// ── FilterBar — declarative filter bar for L1 list pages ─────────────────
const FilterBar = ({ filters = [], advFilter }) => (
  <div className="filter-bar">
    {filters.map((f, i) => {
      if (!f) return null;
      if (f.type === "search") return (
        <Field key={i} leading="search" placeholder={f.placeholder} value={f.value}
          onChange={e => f.onChange(e.target.value)} style={f.style} />
      );
      if (f.type === "dateRange") return (
        <DateRangeChip key={i} label={f.label} from={f.from} to={f.to} onApply={f.onApply} />
      );
      return (
        <ChipFilter key={i} label={f.label} value={f.value} options={f.options}
          onChange={f.onChange} multi={f.multi} />
      );
    })}
    {advFilter && <AdvFilterBtn activeCount={advFilter.activeCount} onClick={advFilter.onClick} />}
  </div>
);

// ── Crumb ────────────────────────────────────────────────────────────────────
const Crumb = ({ items }) => (
  <div className="crumb">
    {items.flatMap((item, i) => {
      const node = item.current
        ? <span key={i} className="cur">{item.label}</span>
        : item.onClick
          ? <a key={i} onClick={item.onClick}>{item.label}</a>
          : <span key={i}>{item.label}</span>;
      return i < items.length - 1
        ? [node, <span key={`s${i}`} className="sep">/</span>]
        : [node];
    })}
  </div>
);

const useDirtyTracker = (saved, current) =>
  React.useMemo(() => JSON.stringify(saved) !== JSON.stringify(current), [saved, current]);

// ── useSortableTable — shared sort state + SortHead + applySortTo ──────────
// Usage:
//   const { SortHead, applySortTo } = useSortableTable("updatedAt", "desc");
//   <SortHead colKey="updatedAt" label="更新時間" />
//   const sorted = applySortTo(filtered);                     // use item[key]
//   const sorted = applySortTo(filtered, (row, key) => ...); // custom valFn
const useSortableTable = (defaultKey, defaultDir = "desc") => {
  const [sort, setSort] = React.useState({ key: defaultKey, dir: defaultDir });
  const sortBy = (key) => setSort(s =>
    s.key === key ? { key, dir: s.dir === "asc" ? "desc" : "asc" } : { key, dir: "asc" }
  );
  const SortHead = ({ colKey, label }) => (
    <span className={"sort" + (sort.key === colKey ? " active" : "")} onClick={() => sortBy(colKey)}>
      {label}
      <Icon name={sort.key === colKey ? (sort.dir === "asc" ? "chevUp" : "chevDown") : "chevDown"} />
    </span>
  );
  const applySortTo = (rows, valFn) => {
    const { key, dir } = sort;
    return [...rows].sort((a, b) => {
      const av = valFn ? valFn(a, key) : (a[key] ?? "");
      const bv = valFn ? valFn(b, key) : (b[key] ?? "");
      if (av == null && bv == null) return 0;
      if (av == null) return dir === "asc" ? 1 : -1;
      if (bv == null) return dir === "asc" ? -1 : 1;
      if (typeof av === "number" && typeof bv === "number") return dir === "asc" ? av - bv : bv - av;
      return dir === "asc"
        ? String(av).localeCompare(String(bv), "zh-Hant")
        : String(bv).localeCompare(String(av), "zh-Hant");
    });
  };
  return { sort, sortBy, SortHead, applySortTo };
};

// ── PageHead — unified page header (L1 + L2) ──────────────────────────────
// crumbs: [{ label, onClick? }] — last item must have current: true
// section: string shorthand for L1 (auto-builds 2-level crumb)
// title: ReactNode — may include Tag / AggStatusDisplay
// subtitle: ReactNode (optional)
// actions: ReactNode (optional) — buttons passed without page__actions wrapper
// dirty: boolean — shows 「未儲存」tag when true
const PageHead = ({ section, crumbs, title, subtitle, actions, dirty }) => {
  const crumbItems = crumbs ?? (section ? [{ label: section }, { label: title, current: true }] : null);
  return (
    <div className="page__head">
      <div>
        {crumbItems && <Crumb items={crumbItems} />}
        <div className="page__title">
          {title}
          {dirty && <span className="dirty-dot" title="尚未儲存的變更"><span className="dirty-dot__bullet" />未儲存</span>}
        </div>
        {subtitle && <div className="page__subtitle">{subtitle}</div>}
      </div>
      <div className="page__actions">{actions}</div>
    </div>
  );
};

// ===== ImportBanner =====================================================
// phase: "parsing" | "ok" | "error" | null
// parsingTitle: override "Excel 解析中…" (e.g. "從列表帶入中…")
// titlePrefix/titleSuffix/desc: override error phase title/desc text
const ImportBanner = ({ phase, parsingTitle, total, done, unit = "筆", errorCount, onClose, onView,
                        titlePrefix, titleSuffix, desc, okTitle, okDesc }) => {
  const [viewIdx, setViewIdx] = useState(0);
  useEffect(() => { setViewIdx(0); }, [errorCount]);
  if (phase === "parsing") {
    const pct = total ? Math.round((done / total) * 100) : 0;
    return (
      <div className="imp-banner imp-banner--busy">
        <div className="imp-banner__spinner" />
        <div className="imp-banner__main">
          <div className="imp-banner__title">{parsingTitle || "Excel 解析中…"}</div>
          <div className="imp-banner__bar"><span style={{ width: pct + "%" }} /></div>
        </div>
        <div className="imp-banner__txt">{done} / {total} {unit} · {pct}%</div>
      </div>
    );
  }
  if (phase === "error") {
    const prefix  = titlePrefix  !== undefined ? titlePrefix  : "解析完成，";
    const suffix  = titleSuffix  !== undefined ? titleSuffix  : "需要修正";
    const descTxt = desc         !== undefined ? desc         : "下方表格中紅色提示的欄位需要修正，點擊即可直接編輯。";
    const handleView = () => {
      const count = errorCount || 1;
      onView?.(viewIdx);
      setViewIdx(i => (i + 1) % count);
    };
    return (
      <div className="imp-banner imp-banner--err">
        <Icon name="alertCircle" size={16} />
        <div className="imp-banner__main">
          <div className="imp-banner__title">{prefix}<b>{errorCount} 筆</b>{suffix}</div>
          <div className="imp-banner__desc">{descTxt}</div>
        </div>
        {onView && <Button variant="default" size="sm" onClick={handleView}>檢視第 {viewIdx + 1} 筆錯誤</Button>}
        {onClose && <Button variant="text" size="sm" iconOnly icon="close" onClick={onClose} title="關閉" />}
      </div>
    );
  }
  if (phase === "ok") {
    const okTitleText = okTitle !== undefined ? okTitle : `已匯入 ${total} 筆品項`;
    const okDescText  = okDesc  !== undefined ? okDesc  : "已全數通過前端檢查，可繼續編輯或送出。";
    return (
      <div className="imp-banner imp-banner--ok">
        <Icon name="check" size={16} />
        <div className="imp-banner__main">
          <div className="imp-banner__title">{okTitleText}</div>
          {okDescText && <div className="imp-banner__desc">{okDescText}</div>}
        </div>
        {onClose && <Button variant="text" size="sm" iconOnly icon="close" onClick={onClose} title="關閉" />}
      </div>
    );
  }
  return null;
};

// Drives a fake progress animation. Returns a cancel fn.
// total: expected item count; onTick(done): called each tick; onDone(): called when complete
const runImportProgress = (total, onTick, onDone, intervalMs = 60) => {
  let done = 0;
  const id = setInterval(() => {
    done = Math.min(done + Math.max(1, Math.ceil(total / 18)), total);
    onTick(done);
    if (done >= total) { clearInterval(id); setTimeout(onDone, 120); }
  }, intervalMs);
  return () => clearInterval(id);
};

const ChangePill = ({ count, hasErr }) => {
  if (count === 0) {
    return <span className="change-pill change-pill--none">尚未編輯</span>;
  }
  if (hasErr) {
    return (
      <span className="change-pill change-pill--err">
        <Icon name="alertCircle" size={12} />
        {count} 項變更
      </span>
    );
  }
  return (
    <span className="change-pill change-pill--ok">
      <Icon name="check" size={12} />
      {count} 項變更
    </span>
  );
};

/* ---------- NumInput — 數值 + 單位後綴（表單用） ---------- */
const NumInput = ({ value, onChange, unit, placeholder, error, disabled }) => (
  <span className={"numinput" + (error ? " numinput--error" : "") + (disabled ? " numinput--disabled" : "")}>
    {unit && <span className="numinput__unit">{unit}</span>}
    <Field
      type="number"
      value={value ?? ""}
      onChange={onChange}
      placeholder={placeholder}
      disabled={disabled}
      style={{ width: "100%" }}
    />
  </span>
);

/* ---------- RadioCard — 帶說明文字的單選卡組 ---------- */
const RadioCard = ({ options, value, onChange, name = "radio-card" }) => (
  <div className="radio-card-row">
    {options.map(c => (
      <label key={c.id} className={"radio-card" + (value === c.id ? " radio-card--active" : "")}>
        <input
          type="radio"
          name={name}
          checked={value === c.id}
          onChange={() => onChange(c.id)}
        />
        <div>
          <div className="radio-card__title">{c.label}</div>
          {c.hint && <div className="radio-card__hint">{c.hint}</div>}
        </div>
      </label>
    ))}
  </div>
);

/* ---------- RadioGroup — inline radio (cv-radio + radio-opt) ----------
   options = [[label, value], ...]；用於 form 內 inline 單選 (vs RadioCard 是大塊卡片型)。
*/
const RadioGroup = ({ options, value, onChange }) => (
  <div className="cv-radio" onClick={e => e.stopPropagation()} onMouseDown={e => e.stopPropagation()}>
    {options.map(([label, val]) => (
      <div key={label}
        className={"radio-opt" + (value === val ? " selected" : "")}
        style={{ cursor: "pointer" }}
        onClick={() => onChange(val)}>
        <div className="radio-dot"><div className="radio-dot-inner" /></div>
        <span>{label}</span>
      </div>
    ))}
  </div>
);

/* ---------- SegControl — 分段切換控制 ---------- */
const SegControl = ({ options, value, onChange }) => (
  <div className="seg-ctrl">
    {options.map(o => (
      <button
        key={o.id}
        type="button"
        className={"seg-ctrl__btn" + (value === o.id ? " on" : "")}
        onClick={() => onChange(o.id)}
      >
        {o.badge && <span className="seg-ctrl__badge">{o.badge}</span>}
        {o.label}
      </button>
    ))}
  </div>
);

/* ---------- StatusSwitch ---------- */
// 啟用 / 停用 toggle，用於列表列與 L2 表單。
// Usage: <StatusSwitch on={enabled} onClick={() => setEnabled(v => !v)} />
const StatusSwitch = ({ on, onClick }) => (
  <span className={"status-sw " + (on ? "status-sw--active" : "status-sw--off")} onClick={onClick} role="switch" aria-checked={on}>
    <span className="status-sw__track"><span className="status-sw__thumb" /></span>
    <span className="status-sw__label">{on ? "啟用" : "停用"}</span>
  </span>
);

/* ---------- FormField + FormFieldVal ---------- */
// Labeled field wrapper — shared by any module that renders a form card.
// Props:
//   label    {string}  — field label text
//   required {bool}   — shows red * after label
//   locked   {bool}   — shows lock icon after label; also dims the label color
//   hint     {string} — optional small descriptor shown below the label
//   className {string} — extra CSS class on the root div (e.g. "form-grid__full")
//   children          — editable control (Field/Select/NumInput) or <FormFieldVal>
const FormField = ({ label, required, locked, hint, error, className = "", style, children }) => (
  <div className={"form-field" + (locked ? " form-field--locked" : "") + (className ? " " + className : "")} style={style}>
    <label className="form-field__lbl">
      {label}
      {required && <span className="form-field__req">*</span>}
      {locked && <Icon name="lock" size={12} />}
    </label>
    {children}
    {error
      ? <div className="form-field__err">{error}</div>
      : hint && <div className="form-field__hint">{hint}</div>}
  </div>
);

// Readonly value box — use inside FormField when the field is not editable.
// Props:
//   mono {bool} — apply monospace font (prices, dates, codes)
const FormFieldVal = ({ mono, children }) => (
  <div className={"form-field__val" + (mono ? " mono" : "")}>{children}</div>
);

const UserChip = ({ name }) =>
  name
    ? <span className="applicant-chip">{name}</span>
    : <span style={{ color: "var(--fg-4)" }}>—</span>;

/* ---------- SectionCard ---------- */
// Collapsible card with a titled header toggle.
// defaultOpen=true  → starts expanded  (use when card contains input fields)
// defaultOpen=false → starts collapsed (use when card is read-only)
// Syncs with defaultOpen changes so isEditable transitions auto-collapse the card.
const SectionCard = ({ title, summary, defaultOpen = true, style, children }) => {
  const [open, setOpen] = React.useState(defaultOpen);
  React.useEffect(() => { setOpen(defaultOpen); }, [defaultOpen]);
  return (
    <div className={`section-card${open ? " section-card--open" : ""}`} style={style}>
      <button className="section-card__toggle" onClick={() => setOpen(v => !v)} aria-expanded={open}>
        <svg className="section-card__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none"
          stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
          <polyline points="9 18 15 12 9 6" />
        </svg>
        <span className="section-card__title">{open ? title : (summary ?? title)}</span>
      </button>
      {open && children}
    </div>
  );
};

const FormDivider = () => (
  <div style={{
    gridColumn: "1 / -1",
    height: 1,
    background: "var(--border-1)",
    margin: "2px 0",
  }} />
);

/* ---------- FormSectionDivider — 表單分類標題（badge + label）----------
   全站唯一的表單分類標題，全頁表單（grid 容器）與側拉抽屜（flow 容器）共用同一視覺。
   badge: 字母序號（A / B / C…）— 省略時只顯示標題文字
   label: 分類名稱
   first: 卡片 / 抽屜內第一個分類，不加上方 margin
   Usage: <FormSectionDivider badge="A" label="識別" first />
          <FormSectionDivider label="折扣方案" />
*/
const FormSectionDivider = ({ badge, label, first }) => (
  <div className={"form-section-head" + (first ? " form-section-head--first" : "")}>
    {badge && <span className="form-section-head__badge">{badge}</span>}
    {label}
  </div>
);

/* ---------- useSubmissionModel — 優惠與活動提交狀態機 ---------- */
// 支援 Collection Offer（co 前綴）與 Eats Offer（eo 前綴）狀態。
// Eats Offer 多一個 eoNoReview：系統依毛利條件自動放行，無需人工審核。
// hasApproval:       模組層級靜態開關（EO 為 true）。
// hasEditPermission: 帳號對此模組有無 edit 權限。
// isApplicant:       currentUser === offer.applicant。
const useSubmissionModel = (offer, hasApproval, hasEditPermission = true, isApplicant = true) => {
  const status = offer?.status || "coDraft";
  const isDraft       = status === "coDraft"       || status === "eoDraft";
  const isUnderReview = status === "coUnderReview" || status === "eoUnderReview";
  const isApproved    = status === "coApproved"    || status === "eoApproved";
  const isRejected    = status === "coRejected"    || status === "eoRejected";
  const isNoReview    = status === "eoNoReview"    || status === "coNoReview";
  const editAllowed   = !!hasEditPermission && !!isApplicant;
  return {
    status,
    isDraft,
    isUnderReview,
    isApproved,
    isRejected,
    isNoReview,
    isReadOnly:     !editAllowed,
    canEdit:        editAllowed && !isUnderReview,
    canAddRow:      editAllowed && !isUnderReview,
    // 退回（已退回）比照草稿：可刪 row、可儲存草稿、可重新送出
    canDeleteRow:   editAllowed && (isDraft || isRejected),
    canSaveDraft:   editAllowed && (isDraft || isRejected),
    canSubmit:      editAllowed && (isDraft || isRejected),
    canSave:        editAllowed && (isApproved || isNoReview),
    showApprovalUI: !!hasApproval,
  };
};

/* ---------- ApprovalStatusBanner ---------- */
const ApprovalStatusBanner = ({ msg, variant = "review" }) => (
  <div className={"approval-status-banner" + (variant === "rejected" ? " approval-status-banner--rejected" : "")}>
    <Icon name={variant === "rejected" ? "alertCircle" : "clock"} size={16} />
    <span>{msg || "此活動待審核，主管審核完成前資料凍結，無法編輯。"}</span>
  </div>
);

/* ---------- SlideDrawer ---------- */
// 通用右側 drawer 容器。封裝 mask、head（title + meta chip + dirty-dot + ×）、
// body、foot 與放棄確認 Dialog，各模組 drawer 只需傳入內容。
// Props:
//   title      {string}              主標題（品名、批次名）
//   meta       {ReactNode}           title 右側的 chip / tag 區（barcode 等）
//   isDirty    {bool}                是否有未儲存變更
//   dirtyCount {number}              dirty-dot 顯示的變更數（選填）
//   dirtyMsg   {string}              放棄確認 dialog 說明文字
//   wide       {bool}                是否套用 drawer--wide（≈880px）
//   onClose    {fn}                  dirty guard 通過後呼叫
//   foot       {ReactNode|fn}        footer 內容；若為 function，接收 tryClose 作為參數
//   children                         drawer body 內容
const SlideDrawer = ({ title, meta, isDirty, dirtyCount, dirtyMsg, wide, onClose, foot, children }) => {
  const [showDiscard, setShowDiscard] = useState(false);
  const tryClose = () => { if (isDirty) setShowDiscard(true); else onClose(); };
  return (
    <>
      <div className="drawer-mask" onClick={tryClose} />
      <div className={"drawer" + (wide ? " drawer--wide" : "")}>
        <div className="drawer__head" style={{ flexDirection: "column", alignItems: "flex-start", gap: 4, padding: "14px 18px" }}>
          <div style={{ display: "flex", alignItems: "center", gap: 8, width: "100%" }}>
            <span className="drawer__title" style={{ flex: "0 1 auto", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
              {title}
            </span>
            {meta}
            {isDirty && (
              <span className="dirty-dot" style={{ flexShrink: 0 }}>
                <span className="dirty-dot__bullet" />
                {dirtyCount != null ? `${dirtyCount} 項未儲存` : "未儲存"}
              </span>
            )}
            <button className="drawer__close" style={{ marginLeft: "auto" }} onClick={tryClose}>
              <Icon name="close" size={16} />
            </button>
          </div>
        </div>
        <div className="drawer__body">{children}</div>
        {foot && (
          <div className="drawer__foot">
            {typeof foot === "function" ? foot(tryClose) : foot}
          </div>
        )}
      </div>
      <Dialog
        open={showDiscard}
        title="放棄未儲存的變更？"
        onClose={() => setShowDiscard(false)}
        maskStyle={{ zIndex: 200 }}
        footer={<>
          <Button variant="text" onClick={() => setShowDiscard(false)}>繼續編輯</Button>
          <Button variant="danger" onClick={onClose}>放棄變更</Button>
        </>}
      >
        <p style={{ font: "400 13px var(--font-sans)", color: "var(--fg-1)", margin: 0 }}>
          {dirtyMsg || "尚有未儲存的變更，關閉後將會遺失。"}
        </p>
      </Dialog>
    </>
  );
};

/* ---------- ADJ 系列「後續處理 / 審核」追蹤欄（ERP / Oracle / 菜單已調整）共用 ----------
   轉廠商 / 價格 / 商品狀態調整三模組共用同一組欄定義與 cell 渲染，單一來源避免漂移。
   ADJ_TRACKING_COLS({ group, erpHint })：回傳 3 個 col 描述（label / 寬度 / InfoTip 文案一致；
     erpHint 因各模組動作不同而參數化；Oracle / 菜單句固定）。各模組 COLS 以 spread 帶入。
   renderAdjTrackingCell(k, row, { canErpUpdate, patchRow })：命中三 key 回傳對應 <td>，否則 null
     （呼叫端 renderCell 的 switch 續走）。erp 欄：可調整者顯示 Check，否則 ✓ / —；oracle / 菜單唯讀 ✓ / —。 */
const ADJ_TRACKING_COLS = ({ group, erpHint }) => ([
  { key: "erpUpdated",    label: "ERP已調整",    w: 110, group, info: erpHint },
  { key: "oracleUpdated", label: "Oracle已調整", w: 120, group, info: "商品調整 Oracle 匯出完成後，由系統自動標記。" },
  { key: "menuUpdated",   label: "菜單已調整",   w: 110, group, info: "每週五菜單版本更新時，若生效日期已到且審核通過，由系統自動標記。" },
]);
const renderAdjTrackingCell = (k, row, { canErpUpdate, patchRow }) => {
  if (k === "erpUpdated") {
    if (canErpUpdate) return (
      <td key={k} style={{ textAlign: "center" }}>
        <Check checked={!!row.erpUpdated} onChange={v => patchRow(row.uid, "erpUpdated", v)} />
      </td>
    );
    return <td key={k} className={row.erpUpdated ? "updated-yes" : "updated-no"} style={{ textAlign: "center" }}>
      {row.erpUpdated ? "✓" : "—"}
    </td>;
  }
  if (k === "oracleUpdated" || k === "menuUpdated") {
    return <td key={k} className={row[k] ? "updated-yes" : "updated-no"} style={{ textAlign: "center" }}>
      {row[k] ? "✓" : "—"}
    </td>;
  }
  return null;
};

Object.assign(window, { Icon, Button, Field, Select, Textarea, NumInput, RadioCard, RadioGroup, SegControl, Tag, STATUS, Check, ToastProvider, useToast, Dialog, SlideDrawer, BulkBar, ChipFilter, DateRangeChip, Pager, ModeToggle, FilterGroup, FilterTagStrip, FilterDrawer, AdvFilterBtn, FdrChipGroup, CellDisplay, CellEditor, PortalSelect, RowActions, TableGroupHeader, MetaCard, SubmitConfirmPage, DeleteRowBtn, InfoTip, InfoNote, SysCheckBanner, EmptyState, SkuToolbar, TruncCell, Crumb, PageHead, FilterBar, ImportBanner, runImportProgress, ChangePill, StatusSwitch, FormField, FormFieldVal, UserChip, FormDivider, FormSectionDivider, SectionCard, useSubmissionModel, ApprovalStatusBanner, ShowErrorsProvider, ErrWrap, ErrIconTip, RowErrTip, useFormValidation, useDirtyTracker, useSortableTable, ADJ_TRACKING_COLS, renderAdjTrackingCell });
