// Ledger — bookkeeping prototype
// Minimal, photography-of-numbers aesthetic. Single blue accent.

const { useState, useEffect, useMemo, useRef, useCallback, useContext, createContext, forwardRef, useImperativeHandle } = React;
const F = window.LEDGER_FMT;
// LEDGER_DATA is assigned by api.js once the session check + initial fetches
// finish; we destructure inside __startApp (bottom of file) so this module can
// load even before the network round-trip completes.
let CATEGORIES, INITIAL_CATEGORIES, TRANSACTIONS, TODAY, MONTH_BUDGET, SLUG_BY_NAME;
const CategoriesContext = createContext({
  cats: CATEGORIES,
  setCats: () => {},
});
const useCats = () => useContext(CategoriesContext);

// ──────────────────────────────────────────────────────────────────────────────
// Top chrome — global nav + sub nav

function GlobalNav({ onAdd, onSearch, search, density, accent, tab, onTab }) {
  return (
    <div className="global-nav">
      <div className="gn-left">
        <div className="logo">
          <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
            <rect x="1" y="1" width="12" height="12" rx="3" fill="currentColor" opacity=".9"/>
            <path d="M4 7.5l2 2L10 5" stroke="#0a0a0c" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
          </svg>
          <span>Ledger</span>
        </div>
        <nav className="gn-links" aria-label="primary">
          <button
            type="button"
            className={'gn-link ' + (tab === 'ledger' ? 'gn-link-active' : '')}
            onClick={() => onTab('ledger')}
          >明細</button>
          <button
            type="button"
            className={'gn-link ' + (tab === 'categories' ? 'gn-link-active' : '')}
            onClick={() => onTab('categories')}
          >分類</button>
          <button
            type="button"
            className={'gn-link ' + (tab === 'recurring' ? 'gn-link-active' : '')}
            onClick={() => onTab('recurring')}
          >固定支出</button>
          <button
            type="button"
            className={'gn-link ' + (tab === 'projects' ? 'gn-link-active' : '')}
            onClick={() => onTab('projects')}
          >專案</button>
          <button
            type="button"
            className={'gn-link ' + (tab === 'settings' ? 'gn-link-active' : '')}
            onClick={() => onTab('settings')}
          >設定</button>
        </nav>
      </div>
      <div className="gn-right">
        <div className="search">
          <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true">
            <circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.3" fill="none"/>
            <path d="M10.5 10.5l3 3" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/>
          </svg>
          <input
            placeholder="搜尋金額、備註、分類…"
            value={search}
            onChange={(e) => onSearch(e.target.value)}
          />
          <kbd>/</kbd>
        </div>
        <button className="btn-primary" type="button" onClick={onAdd}>
          <span className="plus">＋</span>新增
          <kbd className="kbd-on-blue">N</kbd>
        </button>
      </div>
    </div>
  );
}

function SubNav({ month, onPrev, onNext, onToday, rangePreset, onRangePreset, hasCustomRange, onPickMonth }) {
  const label = month.getFullYear() + ' 年 ' + (month.getMonth() + 1) + ' 月';
  const [pickerOpen, setPickerOpen] = useState(false);
  const PRESETS = [
    { id: 'month',     label: '本月' },
    { id: 'lastMonth', label: '上月' },
    { id: 'last7',     label: '過去 7 天' },
    { id: 'last30',    label: '過去 30 天' },
    { id: 'last90',    label: '過去 90 天' },
    { id: 'ytd',       label: '今年至今' },
  ];
  return (
    <div className="sub-nav">
      <div className="sn-left">
        <div className="sn-title-wrap">
          <button
            type="button"
            className="sn-title sn-title-btn"
            onClick={() => setPickerOpen((v) => !v)}
            aria-haspopup="dialog"
            aria-expanded={pickerOpen}
          >
            {label}
            <svg width="11" height="11" viewBox="0 0 12 12" aria-hidden="true" className="sn-caret">
              <path d="M2.5 4.5l3.5 3.5 3.5-3.5" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
            </svg>
          </button>
          {pickerOpen && (
            <MonthPicker
              month={month}
              onPick={(d) => { onPickMonth(d); setPickerOpen(false); }}
              onToday={() => { onToday(); setPickerOpen(false); }}
              onClose={() => setPickerOpen(false)}
            />
          )}
        </div>
        <div className="sn-stepper" role="group" aria-label="月份">
          <button type="button" className="step" onClick={onPrev} aria-label="上一月">‹</button>
          <button type="button" className="step step-today" onClick={onToday}>本月</button>
          <button type="button" className="step" onClick={onNext} aria-label="下一月">›</button>
        </div>
      </div>
      <div className="sn-right">
        <div className="range-presets" role="tablist" aria-label="日期範圍">
          {PRESETS.map((p) => (
            <button
              key={p.id}
              type="button"
              className={'range-chip ' + (rangePreset === p.id && !hasCustomRange ? 'range-chip-on' : '')}
              onClick={() => onRangePreset(p.id)}
            >{p.label}</button>
          ))}
          {hasCustomRange && (
            <span className="range-chip range-chip-on range-chip-custom">自訂</span>
          )}
        </div>
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────────
// Stat row — three big numbers

function StatRow({
  monthTotal, todayTotal, avgPerDay, budget, monthDaysElapsed, monthDaysTotal,
  yesterdayTotal, lastMonthAvg,
  range, rangeTotal, rangeDays, rangeTopDay, rangeAvg, prevRangeAvg,
}) {
  const inRange = !!range;

  // ── No range: original layout (month total / today / day-avg)
  if (!inRange) {
    const pct = Math.min(100, Math.round((monthTotal / budget) * 100));
    const onPace = (monthTotal / monthDaysElapsed) * monthDaysTotal;
    const onPacePct = Math.round((onPace / budget) * 100);
    const dayDelta = yesterdayTotal === 0 ? 0 : Math.round(((todayTotal - yesterdayTotal) / yesterdayTotal) * 100);
    const avgDelta = lastMonthAvg === 0 ? 0 : Math.round(((avgPerDay - lastMonthAvg) / lastMonthAvg) * 100);

    return (
      <div className="stat-row">
        <div className="stat stat-hero">
          <div className="stat-eyebrow">本月花費</div>
          <div className="stat-value">
            <span className="mark">NT$</span>
            <span className="num">{Math.round(monthTotal).toLocaleString('en-US')}</span>
          </div>
          <div className="stat-meta">
            <div className="budget-bar" aria-label={'已用 ' + pct + '%'}>
              <div className="budget-fill" style={{ width: pct + '%' }} />
              <div className="budget-pace" style={{ left: Math.min(100, (monthDaysElapsed / monthDaysTotal) * 100) + '%' }} />
            </div>
            <div className="budget-legend">
              <span>預算 NT$ {budget.toLocaleString('en-US')}</span>
              <span className={onPacePct > 100 ? 'pace pace-over' : 'pace'}>
                預計月底 {Math.round(onPace).toLocaleString('en-US')} · {onPacePct}%
              </span>
            </div>
          </div>
        </div>

        <div className="stat">
          <div className="stat-eyebrow">本日花費</div>
          <div className="stat-value">
            <span className="mark">NT$</span>
            <span className="num">{Math.round(todayTotal).toLocaleString('en-US')}</span>
          </div>
          <div className="stat-delta">
            <span className="delta-date">{`${F.weekday(TODAY)} · ${TODAY.getMonth() + 1}/${TODAY.getDate()}`}</span>
            {yesterdayTotal > 0 && (
              <span className={'delta-pill ' + (dayDelta > 0 ? 'delta-up' : 'delta-down')}>
                {dayDelta > 0 ? '↑' : '↓'} {Math.abs(dayDelta)}% vs 昨日
              </span>
            )}
          </div>
        </div>

        <div className="stat">
          <div className="stat-eyebrow">日均</div>
          <div className="stat-value">
            <span className="mark">NT$</span>
            <span className="num">{Math.round(avgPerDay).toLocaleString('en-US')}</span>
          </div>
          <div className="stat-delta">
            <span className="delta-date">{`過去 ${monthDaysElapsed} 天平均`}</span>
            {lastMonthAvg > 0 && (
              <span className={'delta-pill ' + (avgDelta > 0 ? 'delta-up' : 'delta-down')}>
                {avgDelta > 0 ? '↑' : '↓'} {Math.abs(avgDelta)}% vs 上月
              </span>
            )}
          </div>
        </div>
      </div>
    );
  }

  // ── With range: switch to range-oriented stats
  const sameDay = range.start.getTime() === range.end.getTime();
  const yr = TODAY.getFullYear();
  const fmtMD = (d) => {
    const sameYr = d.getFullYear() === yr;
    return sameYr
      ? `${d.getMonth() + 1}/${d.getDate()}`
      : `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
  };
  const crossYear = range.start.getFullYear() !== range.end.getFullYear()
    || range.start.getFullYear() !== yr
    || range.end.getFullYear() !== yr;
  const fmtMDForRange = (d) => crossYear
    ? `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
    : `${d.getMonth() + 1}/${d.getDate()}`;

  const rangeLabel = sameDay
    ? F.longDate(range.start)
    : `${fmtMDForRange(range.start)} － ${fmtMDForRange(range.end)}`;

  const topPctVsAvg = rangeTopDay && rangeAvg > 0
    ? Math.round(((rangeTopDay.total - rangeAvg) / rangeAvg) * 100)
    : 0;

  const avgDelta = prevRangeAvg > 0
    ? Math.round(((rangeAvg - prevRangeAvg) / prevRangeAvg) * 100)
    : 0;

  return (
    <div className="stat-row stat-row-range">
      <div className="stat stat-hero">
        <div className="stat-eyebrow">區間總花費</div>
        <div className="stat-value">
          <span className="mark">NT$</span>
          <span className="num">{Math.round(rangeTotal).toLocaleString('en-US')}</span>
        </div>
        <div className="stat-delta">
          <span className="delta-date">{rangeLabel} · 共 {rangeDays} 天</span>
        </div>
      </div>

      <div className="stat">
        <div className="stat-eyebrow">最高單日</div>
        {rangeTopDay ? (
          <>
            <div className="stat-value">
              <span className="mark">NT$</span>
              <span className="num">{Math.round(rangeTopDay.total).toLocaleString('en-US')}</span>
            </div>
            <div className="stat-delta">
              <span className="delta-date">
                {`${fmtMD(rangeTopDay.date)} · ${F.weekday(rangeTopDay.date)}`}
              </span>
              {rangeAvg > 0 && topPctVsAvg > 0 && (
                <span className="delta-pill delta-up">↑ {topPctVsAvg}% vs 日均</span>
              )}
            </div>
          </>
        ) : (
          <>
            <div className="stat-value">
              <span className="mark">NT$</span>
              <span className="num">0</span>
            </div>
            <div className="stat-delta">
              <span className="delta-date">區間內無交易</span>
            </div>
          </>
        )}
      </div>

      <div className="stat">
        <div className="stat-eyebrow">區間日均</div>
        <div className="stat-value">
          <span className="mark">NT$</span>
          <span className="num">{Math.round(rangeAvg).toLocaleString('en-US')}</span>
        </div>
        <div className="stat-delta">
          <span className="delta-date">{`平均每日 · 共 ${rangeDays} 天`}</span>
          {prevRangeAvg > 0 && (
            <span className={'delta-pill ' + (avgDelta > 0 ? 'delta-up' : 'delta-down')}>
              {avgDelta > 0 ? '↑' : '↓'} {Math.abs(avgDelta)}% vs 前 {rangeDays} 天
            </span>
          )}
        </div>
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────────
// MonthPicker — popover triggered by clicking the month title

function MonthPicker({ month, onPick, onToday, onClose }) {
  const [year, setYear] = useState(month.getFullYear());
  const months = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
  const popRef = useRef(null);

  useEffect(() => {
    const onDoc = (e) => { if (popRef.current && !popRef.current.contains(e.target)) onClose(); };
    const onEsc = (e) => { if (e.key === 'Escape') onClose(); };
    setTimeout(() => document.addEventListener('mousedown', onDoc));
    document.addEventListener('keydown', onEsc);
    return () => {
      document.removeEventListener('mousedown', onDoc);
      document.removeEventListener('keydown', onEsc);
    };
  }, [onClose]);

  // 6 years on either side of the centered year
  const yearList = [];
  for (let y = year - 6; y <= year + 6; y++) yearList.push(y);

  return (
    <div className="mp-pop" ref={popRef} role="dialog" aria-label="選擇月份">
      <div className="mp-head">
        <button className="mp-step" onClick={() => setYear(year - 1)} type="button" aria-label="上一年">‹</button>
        <div className="mp-year-strip">
          {yearList.map((y) => (
            <button
              key={y}
              type="button"
              className={'mp-year ' + (y === year ? 'mp-year-cur' : '') + ' ' + (y === month.getFullYear() ? 'mp-year-sel' : '')}
              onClick={() => setYear(y)}
            >{y}</button>
          ))}
        </div>
        <button className="mp-step" onClick={() => setYear(year + 1)} type="button" aria-label="下一年">›</button>
      </div>
      <div className="mp-grid">
        {months.map((m, i) => {
          const isSel = i === month.getMonth() && year === month.getFullYear();
          return (
            <button
              key={m}
              type="button"
              className={'mp-cell ' + (isSel ? 'mp-cell-sel' : '')}
              onClick={() => onPick(new Date(year, i, 1))}
            >{m}</button>
          );
        })}
      </div>
      <div className="mp-foot">
        <button type="button" className="mp-today" onClick={onToday}>今天</button>
        <span className="mp-hint">Esc 關閉</span>
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────────
// Calendar with optional heatmap

function Calendar({ month, range, onPickDate, onClearRange, byDay, heatmap, today, pickingHint }) {
  const first = new Date(month.getFullYear(), month.getMonth(), 1);
  const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
  const startWeekday = first.getDay(); // 0 = Sun
  const max = Math.max(1, ...Object.values(byDay));

  // Mobile: default collapsed (CSS-scoped to ≤740px so desktop ignores state).
  const [collapsed, setCollapsed] = useState(true);
  const spentDays = Object.values(byDay).filter((v) => v > 0).length;
  const maxAmount = Object.values(byDay).reduce((a, b) => Math.max(a, b), 0);

  const inRange = (d) => {
    if (!range) return { sel: false, start: false, end: false, between: false, single: false };
    const t = new Date(d); t.setHours(0,0,0,0);
    const s = new Date(range.start); s.setHours(0,0,0,0);
    const e = new Date(range.end);   e.setHours(0,0,0,0);
    const tn = t.getTime(), sn = s.getTime(), en = e.getTime();
    const isSingle = sn === en;
    return {
      sel:     tn >= sn && tn <= en,
      start:   tn === sn,
      end:     tn === en,
      between: tn > sn && tn < en,
      single:  isSingle && tn === sn,
    };
  };

  const cells = [];
  for (let i = 0; i < startWeekday; i++) cells.push({ key: 'b' + i, blank: true });
  for (let d = 1; d <= daysInMonth; d++) {
    const date = new Date(month.getFullYear(), month.getMonth(), d);
    const key = F.dateKey(date);
    const total = byDay[key] || 0;
    cells.push({
      key, date, total,
      isToday: F.sameDay(date, today),
      intensity: Math.min(1, total / max),
      ...inRange(date),
    });
  }
  while (cells.length % 7 !== 0) cells.push({ key: 'a' + cells.length, blank: true });

  return (
    <div className={'calendar ' + (collapsed ? 'calendar--collapsed' : '')}>
      <div className="cal-head">
        <div className="cal-head-l">
          <div className="cal-eyebrow">日曆</div>
          {pickingHint && <span className="cal-pick-hint">{pickingHint}</span>}
        </div>
        <div className="cal-head-r">
          <button
            type="button"
            className="cal-collapse-btn"
            onClick={() => setCollapsed((v) => !v)}
            aria-expanded={!collapsed}
            aria-label={collapsed ? '展開日曆' : '收起日曆'}
          >
            <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
              <path d="M3 5.5l4 4 4-4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
            </svg>
          </button>
          {!range && (
            <div className="cal-legend">
              <span>少</span>
              <i style={{ background: 'var(--heat-1)' }} />
              <i style={{ background: 'var(--heat-2)' }} />
              <i style={{ background: 'var(--heat-3)' }} />
              <i style={{ background: 'var(--heat-4)' }} />
              <span>多</span>
            </div>
          )}
          {range && (
            <button type="button" className="cal-clear" onClick={onClearRange}>
              清除範圍
            </button>
          )}
        </div>
      </div>
      <button
        type="button"
        className="cal-summary"
        onClick={() => setCollapsed(false)}
        aria-label="展開日曆"
      >
        <span className="cal-summary-l">本月 <b>{spentDays}</b> 天有支出</span>
        {maxAmount > 0 && (
          <span className="cal-summary-r">最大 NT$ {maxAmount.toLocaleString('en-US')}</span>
        )}
      </button>
      <div className="cal-grid cal-grid-head">
        {['日','一','二','三','四','五','六'].map((w, i) => (
          <div key={w} className={'cal-dow ' + (i === 0 || i === 6 ? 'cal-dow-end' : '')}>{w}</div>
        ))}
      </div>
      <div className="cal-grid">
        {cells.map((c) => {
          if (c.blank) return <div key={c.key} className="cal-cell cal-blank" />;
          const level = !heatmap ? 0 : (c.total === 0 ? 0 : Math.min(4, Math.ceil(c.intensity * 4)));
          const cls = [
            'cal-cell',
            c.single  ? 'cal-single'  : '',
            c.sel && !c.single ? 'cal-in-range' : '',
            c.start && !c.single ? 'cal-range-start' : '',
            c.end   && !c.single ? 'cal-range-end'   : '',
            c.between ? 'cal-range-between' : '',
            c.isToday ? 'cal-today' : '',
            !c.sel && level ? 'cal-heat-' + level : '',
          ].filter(Boolean).join(' ');
          return (
            <button key={c.key} className={cls} onClick={() => onPickDate(c.date)} type="button">
              <span className="cal-d">{c.date.getDate()}</span>
              {c.total > 0 && (
                <span className="cal-amt">{c.total >= 10000 ? (c.total / 1000).toFixed(1) + 'k' : c.total.toLocaleString('en-US')}</span>
              )}
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────────
// Category ranking (with subcategory drill-down)

function CategoryRank({ totals, subTotals, expanded, onExpand, monthTotal, rangeLabel, onFilter, filterCat, onSeeAll }) {
  const ranked = useMemo(() => {
    return CATEGORIES.map((c) => ({ ...c, total: totals[c.id] || 0 }))
      .filter((c) => c.total > 0)
      .sort((a, b) => b.total - a.total);
  }, [totals]);
  const top = ranked[0]?.total || 1;

  return (
    <div className="rank">
      <div className="rank-head">
        <div className="cal-eyebrow">分類排行</div>
        {onSeeAll && (
          <button type="button" className="rank-seeall" onClick={onSeeAll}>
            全部分類 →
          </button>
        )}
      </div>
      <div className="rank-total-line">{rangeLabel || '本月合計'} NT$ {Math.round(monthTotal).toLocaleString('en-US')}</div>

      <ul className="rank-list">
        {ranked.length === 0 && (
          <li className="rank-empty">
            本範圍尚無交易紀錄
            {onSeeAll && <> · <button className="link" type="button" onClick={onSeeAll}>看全部分類 →</button></>}
          </li>
        )}
        {ranked.map((c, idx) => {
          const pct = (c.total / top) * 100;
          const share = Math.round((c.total / monthTotal) * 100);
          const isOpen = expanded === c.id;
          const subs = subTotals[c.id] || {};
          const subItems = Object.entries(subs).sort((a, b) => b[1] - a[1]);
          return (
            <li key={c.id} className={'rank-item ' + (isOpen ? 'rank-item-open' : '') + ' ' + (filterCat === c.id ? 'rank-item-filtered' : '')}>
              <button className="rank-row" onClick={() => onExpand(isOpen ? null : c.id)} type="button">
                <span className="rank-rank">{String(idx + 1).padStart(2, '0')}</span>
                <span className={'rank-name tx-cat-' + c.slug}>
                  <span className="cat-dot" />
                  {c.name}
                </span>
                <span className="rank-share">{share}%</span>
                <span className="rank-amt">NT$ <b>{c.total.toLocaleString('en-US')}</b></span>
                <span className="rank-caret" aria-hidden="true">{isOpen ? '−' : '+'}</span>
                <span className="rank-bar"><i style={{ width: pct + '%' }} /></span>
              </button>
              {isOpen && (
                <div className="rank-subs">
                  <div className="rank-sub-tools">
                    <button
                      className={'mini-chip ' + (filterCat === c.id ? 'mini-chip-on' : '')}
                      onClick={(e) => { e.stopPropagation(); onFilter(filterCat === c.id ? null : c.id); }}
                      type="button"
                    >{filterCat === c.id ? '取消篩選此分類' : '只看 ' + c.name + '（本範圍）'}</button>
                  </div>
                  <ul className="rank-sub-list">
                    {subItems.map(([name, total]) => {
                      const sPct = (total / subItems[0][1]) * 100;
                      return (
                        <li key={name} className="rank-sub">
                          <span className="rank-sub-name">{name}</span>
                          <span className="rank-sub-amt">NT$ {total.toLocaleString('en-US')}</span>
                          <span className="rank-sub-bar"><i style={{ width: sPct + '%' }} /></span>
                        </li>
                      );
                    })}
                  </ul>
                </div>
              )}
            </li>
          );
        })}
      </ul>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────────
// Bulk action bar — for multi-select delete on transaction list

function BulkActionBar({ count, visibleTx, selectedIds, amount, onSelectAll, onClear, onDelete }) {
  const allSelected = visibleTx.length > 0 && visibleTx.every((t) => selectedIds.has(t.id));
  return (
    <div className="bulk-bar">
      <div className="bulk-bar-l">
        <button
          type="button"
          className="bulk-selectall"
          onClick={() => (allSelected ? onClear() : onSelectAll())}
        >
          <span className={'bulk-checkbox ' + (allSelected ? 'bulk-checkbox-on' : '')}>
            {allSelected && (
              <svg width="10" height="10" viewBox="0 0 12 12">
                <path d="M2.5 6.5L5 9l4.5-5" stroke="#fff" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
              </svg>
            )}
          </span>
          {allSelected ? '取消全選' : '全選 ' + visibleTx.length + ' 筆'}
        </button>
        <div className="bulk-summary">
          已選 <b>{count}</b> 筆
          {count > 0 && <> · NT$ <b>{amount.toLocaleString('en-US')}</b></>}
        </div>
      </div>
      <div className="bulk-bar-r">
        {count > 0 && (
          <button type="button" className="btn-utility" onClick={onClear}>清除</button>
        )}
        <button
          type="button"
          className="btn-confirm-danger"
          onClick={onDelete}
          disabled={count === 0}
        >
          <svg width="12" height="12" viewBox="0 0 16 16">
            <path d="M3 5h10M6 5V3.5A.5.5 0 0 1 6.5 3h3a.5.5 0 0 1 .5.5V5M5.5 5l.5 8.5h4l.5-8.5"
              stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
          </svg>
          刪除 {count > 0 && count}
        </button>
      </div>
    </div>
  );
}


// ──────────────────────────────────────────────────────────────────────────────
// Transaction list

function TxList({ tx, onPick, selectedId, density, hideCents, bulkMode, selectedIds, onToggleSelect }) {
  // group by date desc
  const groups = useMemo(() => {
    const m = new Map();
    tx.forEach((t) => {
      const k = F.dateKey(t.date);
      if (!m.has(k)) m.set(k, { date: t.date, items: [] });
      m.get(k).items.push(t);
    });
    return Array.from(m.values()).sort((a, b) => b.date - a.date);
  }, [tx]);

  if (groups.length === 0) {
    return (
      <div className="tx-empty">
        <div className="tx-empty-mark">－</div>
        <div className="tx-empty-h">這個範圍內沒有紀錄</div>
        <div className="tx-empty-sub">調整篩選或新增第一筆交易</div>
      </div>
    );
  }

  return (
    <div className={'tx-list tx-list-' + density + (bulkMode ? ' tx-list-bulk' : '')}>
      {groups.map((g) => {
        const dayTotal = g.items.reduce((s, t) => s + t.amount, 0);
        return (
          <section key={F.dateKey(g.date)} className="tx-group">
            <header className="tx-day">
              <div className="tx-day-l">
                <span className="tx-day-rel">{F.relativeDay(g.date, TODAY)}</span>
                <span className="tx-day-dot">·</span>
                <span className="tx-day-date">{F.longDate(g.date)}</span>
              </div>
              <div className="tx-day-r">
                <span className="tx-day-count">{g.items.length} 筆</span>
                <span className="tx-day-total">NT$ {dayTotal.toLocaleString('en-US')}</span>
              </div>
            </header>
            <ul className="tx-rows">
              {g.items.map((t) => {
                const isSelected = bulkMode && selectedIds?.has(t.id);
                return (
                  <li
                    key={t.id}
                    className={'tx-row ' + (t.id === selectedId ? 'tx-row-sel' : '') + (isSelected ? ' tx-row-checked' : '')}
                    onClick={() => bulkMode ? onToggleSelect(t.id) : onPick(t)}
                  >
                    {bulkMode && (
                      <span
                        className="tx-check"
                        onClick={(e) => { e.stopPropagation(); onToggleSelect(t.id); }}
                      >
                        <input
                          type="checkbox"
                          checked={isSelected}
                          readOnly
                          aria-label={`選取 ${t.name || t.note}`}
                        />
                      </span>
                    )}
                    <span className={'tx-cat tx-cat-' + t.categorySlug}>
                      <span className="cat-dot" />
                      <span className="tx-cat-text" title={t.category + '／' + t.sub}>
                        {t.category}<span className="tx-sub">／{t.sub}</span>
                      </span>
                    </span>
                    <span className="tx-note">
                      <span className="tx-name" title={t.name}>{t.name}</span>
                      {t.note && <span className="tx-note-extra" title={t.note}>{t.note}</span>}
                    </span>
                    {t.account && <span className="tx-acct">{t.account}</span>}
                    {t.time && <span className="tx-time">{t.time}</span>}
                    {t.currencyOriginal && t.amountOriginal != null && (
                      <span className="tx-fx" title="外幣原始金額">
                        {Number(t.amountOriginal).toLocaleString('en-US')} {t.currencyOriginal}
                      </span>
                    )}
                    <span className="tx-amt">
                      <span className="tx-amt-mark">NT$</span>
                      <span className="tx-amt-num">{t.amount.toLocaleString('en-US')}{hideCents ? '' : '.00'}</span>
                    </span>
                  </li>
                );
              })}
            </ul>
          </section>
        );
      })}
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────────
// Charts (demo) — daily bars + category donut + horizontal bars

function Charts({ rangeTx, range, totals }) {
  // Build per-day data points across the full range
  const dayBuckets = useMemo(() => {
    if (!rangeTx.length) return [];
    const start = new Date(range.start); start.setHours(0,0,0,0);
    const end   = new Date(range.end);   end.setHours(0,0,0,0);
    const days = Math.round((end - start) / 86400000) + 1;
    const out = [];
    for (let i = 0; i < days; i++) {
      const d = new Date(start);
      d.setDate(start.getDate() + i);
      out.push({ date: d, total: 0 });
    }
    rangeTx.forEach((t) => {
      const idx = Math.round((new Date(t.date).setHours(0,0,0,0) - start.getTime()) / 86400000);
      if (idx >= 0 && idx < out.length) out[idx].total += t.amount;
    });
    return out;
  }, [rangeTx, range]);

  const ranked = useMemo(() => {
    return CATEGORIES.map((c) => ({ ...c, total: totals[c.id] || 0 }))
      .filter((c) => c.total > 0)
      .sort((a, b) => b.total - a.total);
  }, [totals]);
  const grandTotal = ranked.reduce((s, c) => s + c.total, 0);

  const maxDay = Math.max(1, ...dayBuckets.map((d) => d.total));
  const avgDay = dayBuckets.length ? grandTotal / dayBuckets.length : 0;

  // Donut geometry
  const R = 78, r = 50; // outer / inner radius
  const cx = 100, cy = 100;
  let cursor = -Math.PI / 2; // start at 12 o'clock
  const slices = ranked.map((c) => {
    const portion = c.total / grandTotal;
    const a0 = cursor;
    const a1 = cursor + portion * Math.PI * 2;
    cursor = a1;
    const large = portion > 0.5 ? 1 : 0;
    const x0 = cx + R * Math.cos(a0), y0 = cy + R * Math.sin(a0);
    const x1 = cx + R * Math.cos(a1), y1 = cy + R * Math.sin(a1);
    const x0i = cx + r * Math.cos(a0), y0i = cy + r * Math.sin(a0);
    const x1i = cx + r * Math.cos(a1), y1i = cy + r * Math.sin(a1);
    const d = `M ${x0} ${y0} A ${R} ${R} 0 ${large} 1 ${x1} ${y1} L ${x1i} ${y1i} A ${r} ${r} 0 ${large} 0 ${x0i} ${y0i} Z`;
    return { ...c, portion, d };
  });

  // Skip every Nth label to avoid crowding
  const labelEvery = dayBuckets.length > 60 ? 14 : dayBuckets.length > 30 ? 7 : dayBuckets.length > 14 ? 3 : 1;

  return (
    <div className="charts">
      <div className="chart-block">
        <div className="chart-head">
          <div className="cal-eyebrow">每日花費</div>
          <div className="chart-head-meta">
            <span className="legend-dot legend-bar" /> 每日合計
            <span className="legend-dot legend-line" /> 區間平均
          </div>
        </div>
        <div className="chart-bars">
          <div className="bar-canvas">
            <div
              className="bar-avg-line"
              style={{ bottom: `${(avgDay / maxDay) * 100}%` }}
              aria-label={`平均 NT$ ${Math.round(avgDay)}`}
            />
            <div className="bars">
              {dayBuckets.map((d, i) => {
                const isToday = F.sameDay(d.date, TODAY);
                const h = (d.total / maxDay) * 100;
                return (
                  <div className="bar-track" key={i}>
                    <div
                      className={'bar ' + (isToday ? 'bar-today' : '')}
                      style={{ height: `${h}%` }}
                      title={`${d.date.getMonth() + 1}/${d.date.getDate()} · NT$ ${d.total.toLocaleString('en-US')}`}
                    />
                  </div>
                );
              })}
            </div>
          </div>
          <div className="bar-labels">
            {dayBuckets.map((d, i) => {
              const show = i % labelEvery === 0 || i === dayBuckets.length - 1;
              return (
                <div className="bar-lbl-col" key={i}>
                  {show && <span>{(d.date.getMonth() + 1) + '/' + d.date.getDate()}</span>}
                </div>
              );
            })}
          </div>
        </div>
        <div className="chart-stats">
          <div className="cs-item"><span className="cs-k">區間總計</span><b>NT$ {grandTotal.toLocaleString('en-US')}</b></div>
          <div className="cs-item"><span className="cs-k">日均</span><b>NT$ {Math.round(avgDay).toLocaleString('en-US')}</b></div>
          <div className="cs-item"><span className="cs-k">單日最高</span><b>NT$ {Math.round(maxDay).toLocaleString('en-US')}</b></div>
          <div className="cs-item"><span className="cs-k">交易筆數</span><b>{rangeTx.length}</b></div>
        </div>
      </div>

      <div className="chart-pair">
        <div className="chart-block chart-donut">
          <div className="chart-head">
            <div className="cal-eyebrow">分類佔比</div>
          </div>
          <div className="donut-wrap">
            <svg viewBox="0 0 200 200" width="200" height="200">
              {slices.length === 0 && (
                <circle cx={cx} cy={cy} r={R} fill="var(--heat-0)" />
              )}
              {slices.map((s) => (
                <path key={s.id} d={s.d} className={'donut-slice donut-' + s.slug} />
              ))}
              <text x="100" y="96" textAnchor="middle" className="donut-num">
                {grandTotal >= 100000 ? (grandTotal / 1000).toFixed(0) + 'k' : grandTotal.toLocaleString('en-US')}
              </text>
              <text x="100" y="114" textAnchor="middle" className="donut-lbl">NT$ · 區間合計</text>
            </svg>
            <ul className="donut-legend">
              {ranked.map((c) => (
                <li key={c.id} className="dl-item">
                  <span className={'dl-dot tx-cat-' + c.slug}><span className="cat-dot" /></span>
                  <span className="dl-name">{c.name}</span>
                  <span className="dl-pct">{Math.round((c.total / grandTotal) * 100)}%</span>
                </li>
              ))}
            </ul>
          </div>
        </div>

        <div className="chart-block chart-hbar">
          <div className="chart-head">
            <div className="cal-eyebrow">分類橫條</div>
          </div>
          <ul className="hbar-list">
            {ranked.map((c) => (
              <li key={c.id} className="hbar-row">
                <span className={'hbar-name tx-cat-' + c.slug} title={c.name}>
                  <span className="cat-dot" />
                  {c.name}
                </span>
                <span className="hbar-track">
                  <i className={'hbar-fill cat-fill-' + c.slug} style={{ width: ((c.total / ranked[0].total) * 100) + '%' }} />
                </span>
                <span className="hbar-amt">NT$ {c.total.toLocaleString('en-US')}</span>
              </li>
            ))}
          </ul>
        </div>
      </div>

      <div className="chart-note">
        圖表為 demo，後續可加：月間對比、預算燃盡圖、消費時段熱圖、Top 商家排行。
      </div>
    </div>
  );
}



// ──────────────────────────────────────────────────────────────────────────────
// CategoriesPage — full-tab category exploration

function CategoriesPage({
  data, range, rangeTotal, totals, lifetimeTotals,
  categoryFocus, setCategoryFocus, subFilter, setSubFilter,
  search, setSearch, density, hideCents, onPick, editing,
  onManage,
}) {
  const { cats } = useCats();
  const hasRange = !!range;
  const rangeLabel = hasRange
    ? (range.start.getTime() === range.end.getTime()
        ? F.longDate(range.start)
        : `${range.start.getFullYear()}/${range.start.getMonth() + 1}/${range.start.getDate()} － ${range.end.getFullYear()}/${range.end.getMonth() + 1}/${range.end.getDate()}`)
    : '全部時間';

  // ─── Overview mode: 8 tiles ───────────────────────────────────────────
  if (!categoryFocus) {
    return (
      <div className="cats-page">
        <div className="cats-page-head">
          <div className="cats-page-head-l">
            <div className="cats-page-eyebrow">分類分析</div>
            <h2 className="cats-page-title">所有分類</h2>
            <p className="cats-page-sub">
              目前範圍：<b>{rangeLabel}</b>
              {hasRange && <> · 合計 NT$ {Math.round(rangeTotal).toLocaleString('en-US')}</>}
              {!hasRange && <> · 點上方範圍 chip 可篩選日期</>}
            </p>
          </div>
          <div className="cats-page-head-r">
            <button
              type="button"
              className="btn-utility-ghost"
              onClick={() => onManage && onManage()}
            >
              管理分類
            </button>
          </div>
        </div>

        <ul className="cats-overview">
          {cats.map((c) => {
            const lt = lifetimeTotals[c.id] || 0;
            const rt = totals[c.id] || 0;
            const primary = hasRange ? rt : lt;
            const secondary = hasRange ? lt : null;
            const sparkData = buildSpark(data, c.id, 12);
            const empty = primary === 0;
            return (
              <li key={c.id}>
                <button
                  type="button"
                  className={'cats-tile ' + (empty ? 'cats-tile-empty' : '')}
                  onClick={() => setCategoryFocus(c.id)}
                >
                  <div className="cats-tile-head">
                    <span className={'cats-tile-icon tx-cat-' + c.slug}>
                      <span className="cat-dot" />
                    </span>
                    <div className="cats-tile-title-wrap">
                      <div className="cats-tile-name">{c.name}</div>
                      <div className="cats-tile-meta">
                        {c.sub.length} 個子分類
                        {hasRange && rt > 0 && <span className="cats-tile-active-pip" title="本範圍有記錄" />}
                      </div>
                    </div>
                    <span className="cats-tile-arrow">→</span>
                  </div>
                  <div className="cats-tile-amt">
                    {primary > 0 ? (
                      <>
                        <span className="cats-tile-amt-mark">NT$</span>
                        <span className="cats-tile-amt-num">{primary.toLocaleString('en-US')}</span>
                      </>
                    ) : (
                      <span className="cats-tile-empty-txt">{hasRange ? '本範圍無記錄' : '尚無記錄'}</span>
                    )}
                  </div>
                  <Sparkline data={sparkData} slug={c.slug} />
                  <div className="cats-tile-foot">
                    <span>近 12 個月趨勢</span>
                    {hasRange && secondary > 0 && <span className="cats-tile-rt">全部 NT$ {secondary.toLocaleString('en-US')}</span>}
                    {!hasRange && rt > 0 && <span className="cats-tile-rt">本範圍 NT$ {rt.toLocaleString('en-US')}</span>}
                  </div>
                </button>
              </li>
            );
          })}
        </ul>
      </div>
    );
  }

  return (
    <CategoryDetail
      data={data}
      range={range}
      categoryId={categoryFocus}
      onBack={() => { setCategoryFocus(null); setSubFilter(null); }}
      subFilter={subFilter}
      setSubFilter={setSubFilter}
      search={search}
      setSearch={setSearch}
      density={density}
      hideCents={hideCents}
      onPick={onPick}
      editing={editing}
    />
  );
}

function buildSpark(data, categoryId, months = 12) {
  const today = TODAY;
  const out = [];
  for (let i = months - 1; i >= 0; i--) {
    const m = new Date(today.getFullYear(), today.getMonth() - i, 1);
    const next = new Date(m.getFullYear(), m.getMonth() + 1, 1);
    const total = data
      .filter((t) => t.categoryId === categoryId && t.date >= m && t.date < next)
      .reduce((s, t) => s + t.amount, 0);
    out.push({ month: m, total });
  }
  return out;
}

function Sparkline({ data, slug }) {
  if (!data.length) return <div className="sparkline-empty" />;
  const max = Math.max(1, ...data.map((d) => d.total));
  const W = 220, H = 36;
  const stepX = W / Math.max(1, data.length - 1);
  const pts = data.map((d, i) => ({
    x: i * stepX,
    y: H - (d.total / max) * (H - 4) - 2,
    total: d.total,
  }));
  const path = pts.map((p, i) => (i === 0 ? 'M' : 'L') + p.x.toFixed(1) + ' ' + p.y.toFixed(1)).join(' ');
  const area = path + ` L ${W} ${H} L 0 ${H} Z`;
  const isEmpty = data.every((d) => d.total === 0);

  return (
    <svg viewBox={`0 0 ${W} ${H}`} className={'spark ' + (isEmpty ? 'spark-empty' : 'spark-cat-' + slug)} preserveAspectRatio="none" width="100%" height="36">
      <path d={area} className="spark-area" />
      <path d={path} className="spark-line" />
      {pts.length > 0 && (
        <circle cx={pts[pts.length - 1].x} cy={pts[pts.length - 1].y} r="2.5" className="spark-end" />
      )}
    </svg>
  );
}

function CategoryDetail({ data, range, categoryId, onBack, subFilter, setSubFilter, search, setSearch, density, hideCents, onPick, editing }) {
  const cat = CATEGORIES.find((c) => c.id === categoryId);
  const hasRange = !!range;

  // All-time tx of this category (used for monthly chart context)
  const allTimeTx = useMemo(() => data.filter((t) => t.categoryId === categoryId), [data, categoryId]);

  // Range-applied tx (used for stats, sub-cat list, transactions list)
  const allTx = useMemo(() => {
    if (!hasRange) return allTimeTx;
    const s = new Date(range.start); s.setHours(0,0,0,0);
    const e = new Date(range.end);   e.setHours(0,0,0,0);
    const sn = s.getTime(), en = e.getTime();
    return allTimeTx.filter((t) => {
      const td = new Date(t.date); td.setHours(0,0,0,0);
      const tn = td.getTime();
      return tn >= sn && tn <= en;
    });
  }, [allTimeTx, hasRange, range]);

  const total = useMemo(() => allTx.reduce((s, t) => s + t.amount, 0), [allTx]);
  const allTimeTotal = useMemo(() => allTimeTx.reduce((s, t) => s + t.amount, 0), [allTimeTx]);

  const rangeLabel = hasRange
    ? (range.start.getTime() === range.end.getTime()
        ? F.longDate(range.start)
        : `${range.start.getFullYear()}/${range.start.getMonth() + 1}/${range.start.getDate()} － ${range.end.getFullYear()}/${range.end.getMonth() + 1}/${range.end.getDate()}`)
    : '全部時間';

  const subTotals = useMemo(() => {
    const m = {};
    allTx.forEach((t) => { m[t.sub] = (m[t.sub] || 0) + t.amount; });
    return m;
  }, [allTx]);
  const subItems = Object.entries(subTotals).sort((a, b) => b[1] - a[1]);

  // Monthly chart — always show 24 months for longitudinal context, regardless of range
  const monthly = useMemo(() => {
    const start = new Date(TODAY.getFullYear() - 1, TODAY.getMonth() + 1 - 23, 1);
    const months = [];
    for (let i = 0; i < 24; i++) {
      const m = new Date(start.getFullYear(), start.getMonth() + i, 1);
      months.push({ month: m, total: 0, inRange: false });
    }
    allTimeTx.forEach((t) => {
      const m = new Date(t.date.getFullYear(), t.date.getMonth(), 1);
      const idx = months.findIndex((x) => x.month.getTime() === m.getTime());
      if (idx >= 0) months[idx].total += t.amount;
    });
    if (hasRange) {
      months.forEach((mm) => {
        const monthStart = mm.month;
        const monthEnd = new Date(mm.month.getFullYear(), mm.month.getMonth() + 1, 0);
        if (monthEnd >= range.start && monthStart <= range.end) mm.inRange = true;
      });
    }
    return months;
  }, [allTimeTx, hasRange, range]);

  const monthsWithData = hasRange
    ? monthly.filter((m) => m.total > 0 && m.inRange).length
    : monthly.filter((m) => m.total > 0).length;
  const monthlyAvg = monthsWithData > 0 ? total / monthsWithData : 0;
  const maxMonthly = Math.max(1, ...monthly.map((m) => m.total));
  const topSub = subItems[0]?.[0] || '—';
  const earliest = allTimeTx.length ? new Date(Math.min(...allTimeTx.map((t) => t.date.getTime()))) : null;

  const filtered = useMemo(() => {
    return allTx.filter((t) => {
      if (subFilter && t.sub !== subFilter) return false;
      if (search) {
        const q = search.toLowerCase();
        if (!((t.name || '').toLowerCase().includes(q) || (t.note || '').toLowerCase().includes(q) || t.sub.includes(search) || String(t.amount).includes(search))) return false;
      }
      return true;
    });
  }, [allTx, subFilter, search]);

  return (
    <div className="cat-detail">
      <button type="button" className="cat-detail-back" onClick={onBack}>← 全部分類</button>

      <header className={'cat-detail-head tx-cat-' + cat.slug}>
        <span className="cat-detail-icon"><span className="cat-dot" /></span>
        <div className="cat-detail-head-text">
          <h2 className="cat-detail-name">{cat.name}</h2>
          <div className="cat-detail-sub">
            {rangeLabel} · {allTx.length} 筆交易
            {hasRange && allTimeTotal > 0 && allTimeTotal !== total && (
              <> · 全部時間 NT$ {allTimeTotal.toLocaleString('en-US')}</>
            )}
          </div>
        </div>
        <div className="cat-detail-amt">
          <span className="mark">NT$</span>
          <span className="num">{total.toLocaleString('en-US')}</span>
        </div>
      </header>

      <div className="cat-detail-stats">
        <div className="cs-item">
          <span className="cs-k">{hasRange ? '範圍月均' : '月均花費'}</span>
          <b>NT$ {Math.round(monthlyAvg).toLocaleString('en-US')}</b>
          <span className="cs-sub">{monthsWithData} 個月有記錄</span>
        </div>
        <div className="cs-item">
          <span className="cs-k">最常子分類</span>
          <b>{topSub}</b>
          <span className="cs-sub">{subItems[0] ? 'NT$ ' + subItems[0][1].toLocaleString('en-US') : '—'}</span>
        </div>
        <div className="cs-item">
          <span className="cs-k">子分類覆蓋</span>
          <b>{subItems.length} / {cat.sub.length}</b>
          <span className="cs-sub">已使用 / 可用</span>
        </div>
        <div className="cs-item">
          <span className="cs-k">最早記錄</span>
          <b>{earliest ? (earliest.getFullYear() + '/' + (earliest.getMonth() + 1)) : '—'}</b>
          <span className="cs-sub">至今</span>
        </div>
      </div>

      <div className="cat-detail-grid">
        <section className="cat-detail-card">
          <div className="chart-head">
            <div className="cal-eyebrow">月度走勢</div>
            <div className="chart-head-meta">近 24 個月 · {hasRange ? '範圍內加深' : '當前月加粗'}</div>
          </div>
          <div className="cat-monthly">
            <div className="bar-canvas" style={{ height: 120 }}>
              <div className="bars">
                {monthly.map((m, i) => {
                  const h = (m.total / maxMonthly) * 100;
                  const isCur = m.month.getMonth() === TODAY.getMonth() && m.month.getFullYear() === TODAY.getFullYear();
                  const isHighlighted = hasRange ? m.inRange : isCur;
                  return (
                    <div className="bar-track" key={i}>
                      <div
                        className={'bar bar-cat cat-bg-' + cat.slug + (isHighlighted ? ' bar-highlight' : '') + (hasRange && !m.inRange ? ' bar-dim' : '')}
                        style={{ height: `${h}%` }}
                        title={`${m.month.getFullYear()}/${m.month.getMonth() + 1} · NT$ ${m.total.toLocaleString('en-US')}`}
                      />
                    </div>
                  );
                })}
              </div>
            </div>
            <div className="bar-labels">
              {monthly.map((m, i) => {
                const show = i % 3 === 0 || i === monthly.length - 1;
                const lbl = m.month.getMonth() === 0 ? m.month.getFullYear() : (m.month.getMonth() + 1) + '月';
                return (
                  <div className="bar-lbl-col" key={i}>{show && <span>{lbl}</span>}</div>
                );
              })}
            </div>
          </div>
        </section>

        <section className="cat-detail-card">
          <div className="chart-head">
            <div className="cal-eyebrow">子分類佔比</div>
            <div className="chart-head-meta">{rangeLabel}</div>
          </div>
          {subItems.length === 0 ? (
            <div className="cat-empty-block">範圍內無此分類的記錄</div>
          ) : (
            <ul className="hbar-list">
              {subItems.map(([s, amt]) => (
                <li key={s} className="hbar-row">
                  <button
                    type="button"
                    title={s}
                    className={'hbar-name hbar-name-btn ' + (subFilter === s ? 'hbar-name-on' : '')}
                    onClick={() => setSubFilter(subFilter === s ? null : s)}
                  >{s}</button>
                  <span className="hbar-track">
                    <i className={'hbar-fill cat-fill-' + cat.slug} style={{ width: ((amt / subItems[0][1]) * 100) + '%' }} />
                  </span>
                  <span className="hbar-amt">NT$ {amt.toLocaleString('en-US')}</span>
                </li>
              ))}
            </ul>
          )}
        </section>
      </div>

      <section className="cat-detail-card cat-detail-list">
        <div className="list-head">
          <div className="list-head-l">
            <div className="cal-eyebrow">交易明細</div>
            <div className="list-head-meta">
              顯示 {filtered.length} / {allTx.length} 筆 · {rangeLabel}
              {subFilter && <span className="tag">{subFilter}</span>}
              {search && <span className="tag">"{search}"</span>}
              {(subFilter || search) && (
                <button className="link" onClick={() => { setSubFilter(null); setSearch(''); }} type="button">清除子篩選</button>
              )}
            </div>
          </div>
        </div>
        <div className="list-filter-row">
          <button
            className={'chip ' + (subFilter == null ? 'chip-on' : '')}
            onClick={() => setSubFilter(null)}
            type="button"
          >全部子分類</button>
          {subItems.map(([s, amt]) => (
            <button
              key={s}
              className={'chip chip-sub ' + (subFilter === s ? 'chip-on' : '')}
              onClick={() => setSubFilter(subFilter === s ? null : s)}
              type="button"
            >
              {s}
              <span className="chip-count">{amt >= 10000 ? (amt/1000).toFixed(1) + 'k' : amt.toLocaleString('en-US')}</span>
            </button>
          ))}
        </div>
        <TxList tx={filtered} onPick={onPick} selectedId={editing?.id} density={density} hideCents={hideCents} />
      </section>
    </div>
  );
}


function ForeignCurrencyField({ draft, setDraft }) {
  const hasFx = draft.currencyOriginal && draft.amountOriginal != null;
  const [open, setOpen] = useState(!!hasFx);
  const clear = () => setDraft({
    ...draft, amountOriginal: null, currencyOriginal: '', fxRate: null,
  });
  if (!open) {
    return (
      <div className="field">
        <button
          type="button"
          className="btn-utility-ghost"
          onClick={() => setOpen(true)}
        >＋ 外幣輸入</button>
      </div>
    );
  }
  return (
    <>
      <div className="field">
        <label>外幣金額</label>
        <input
          type="number"
          step="0.01"
          min="0"
          value={draft.amountOriginal != null ? draft.amountOriginal : ''}
          onChange={(e) => setDraft({
            ...draft,
            amountOriginal: e.target.value === '' ? null : Number(e.target.value),
          })}
          placeholder="例：1234"
        />
      </div>
      <div className="field">
        <label>幣別</label>
        <input
          value={draft.currencyOriginal || ''}
          onChange={(e) => setDraft({
            ...draft, currencyOriginal: e.target.value.toUpperCase(),
          })}
          placeholder="USD / JPY / EUR…"
          list="fx-currency-suggestions"
          maxLength={3}
        />
        <datalist id="fx-currency-suggestions">
          <option value="USD" />
          <option value="JPY" />
          <option value="EUR" />
          <option value="CNY" />
          <option value="HKD" />
          <option value="KRW" />
        </datalist>
      </div>
      <div className="field">
        <label>匯率（1 外幣 = ? TWD）</label>
        <input
          type="number"
          step="0.0001"
          min="0"
          value={draft.fxRate != null ? draft.fxRate : ''}
          onChange={(e) => setDraft({
            ...draft,
            fxRate: e.target.value === '' ? null : Number(e.target.value),
          })}
          placeholder="例：0.231"
        />
      </div>
      <div className="field">
        <button
          type="button"
          className="btn-text-danger"
          onClick={() => { clear(); setOpen(false); }}
        >清除外幣</button>
      </div>
    </>
  );
}


// ──────────────────────────────────────────────────────────────────────────────
// AttachmentsSection — view + edit. Mounted inside DetailPanel for an existing
// record (attachments require record_id, so new records must be saved first).
// Client-side guards (size + extension) keep obvious bad uploads off the wire,
// but the server enforces the real allowlist + magic-byte sniff.

const ATTACH_MAX_BYTES = 10 * 1024 * 1024;
const ATTACH_ACCEPT = '.jpg,.jpeg,.png,.webp,.heic,.heif,.pdf,image/*,application/pdf';
const ATTACH_ALLOWED_MIME_PREFIXES = ['image/', 'application/pdf'];

function _formatAttachSize(bytes) {
  if (bytes == null) return '';
  if (bytes < 1024) return bytes + ' B';
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
  return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}

// AttachmentLightbox — full-screen modal preview for the M4 attachment grid.
// Patterned on new_tab_UI/new-screens-detail.jsx but trimmed: no rotate
// (would require server-side or canvas + re-upload), no client-side
// pinch-zoom (out of scope), no in-modal upload (use the section instead).
//
// Renders nothing when `items` is empty or `openIndex` is null — parent
// controls visibility by setting/clearing openIndex.
function AttachmentLightbox({ items, openIndex, onClose, onDelete, editable }) {
  const api = window.LEDGER_API || {};
  const [idx, setIdx] = useState(openIndex);
  const [previewUrl, setPreviewUrl] = useState(null);
  const [loadError, setLoadError] = useState('');

  // Reset when caller opens a different attachment.
  useEffect(() => {
    setIdx(openIndex);
  }, [openIndex]);

  const active = openIndex != null && items && items.length > 0;
  const att = active ? items[idx != null ? idx : 0] : null;
  const isPdf = att && att.mime_type === 'application/pdf';
  const isHeic = att && (
    (att.mime_type || '').includes('heic') ||
    (att.mime_type || '').includes('heif')
  );

  // Fetch a fresh inline-disposition URL whenever the active attachment changes.
  // Each presigned URL has a 5-min TTL so we can re-sign on next open without
  // worrying about cache staleness.
  useEffect(() => {
    if (!active || !att) return;
    setPreviewUrl(null);
    setLoadError('');
    if (isHeic || isPdf) return;   // HEIC can't preview inline; PDF uses embed
    let cancelled = false;
    api.apiAttachmentUrl(att.id, 'inline').then((resp) => {
      if (cancelled || !resp || !resp.url) return;
      setPreviewUrl(resp.url);
    }).catch((err) => {
      if (cancelled) return;
      setLoadError(err && err.message || '載入失敗');
    });
    return () => { cancelled = true; };
  }, [active, att && att.id, isHeic, isPdf]);

  // Keyboard: Esc closes, arrows navigate. Mounted only while open so we
  // don't compete with the rest of the app for keys.
  useEffect(() => {
    if (!active) return;
    const onKey = (e) => {
      if (e.key === 'Escape') {
        e.preventDefault();
        onClose();
      } else if (e.key === 'ArrowLeft' && items.length > 1) {
        e.preventDefault();
        setIdx((i) => ((i == null ? 0 : i) - 1 + items.length) % items.length);
      } else if (e.key === 'ArrowRight' && items.length > 1) {
        e.preventDefault();
        setIdx((i) => ((i == null ? 0 : i) + 1) % items.length);
      }
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [active, items.length, onClose]);

  if (!active || !att) return null;

  async function _download() {
    try {
      const resp = await api.apiAttachmentUrl(att.id, 'attachment');
      if (resp && resp.url) {
        // <a download> is friendlier than window.open because the browser
        // honours Content-Disposition without spawning a tab; noreferrer
        // strips the Referer so R2 doesn't see the app URL.
        const a = document.createElement('a');
        a.href = resp.url;
        a.rel = 'noopener noreferrer';
        a.download = att.original_filename || '';
        document.body.appendChild(a);
        a.click();
        a.remove();
      }
    } catch (err) {
      setLoadError(err && err.message || '下載失敗');
    }
  }

  function _delete() {
    if (!editable || !onDelete) return;
    if (!window.confirm('刪除附件「' + (att.original_filename || '此檔') + '」？')) return;
    onDelete(att);
  }

  const counter = items.length > 1 ? `${(idx ?? 0) + 1} / ${items.length}` : '';

  return (
    <div className="lb-overlay" role="dialog" aria-label="附件預覽" onClick={onClose}>
      <div className="lb-inner" onClick={(e) => e.stopPropagation()}>
        <div className="lb-top">
          <div className="lb-top-name">
            <b>{att.original_filename || '(未命名)'}</b>
            <span>{_formatAttachSize(att.size_bytes)} · {att.mime_type}</span>
          </div>
          {counter && <span className="lb-counter">{counter}</span>}
          <div className="lb-top-actions">
            <button className="lb-act" title="下載" onClick={_download} type="button">
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
              </svg>
            </button>
            {editable && (
              <button className="lb-act is-danger" title="刪除附件" onClick={_delete} type="button">
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                  <polyline points="3 6 5 6 21 6"/><path d="M19 6l-1.5 14a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>
                </svg>
              </button>
            )}
            <button className="lb-act" title="關閉" onClick={onClose} type="button" style={{ marginLeft: 8 }}>
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
              </svg>
            </button>
          </div>
        </div>

        <div className="lb-stage">
          {items.length > 1 && (
            <button
              type="button"
              className="lb-nav prev"
              onClick={() => setIdx((i) => ((i == null ? 0 : i) - 1 + items.length) % items.length)}
              aria-label="上一張"
            >‹</button>
          )}
          <div className="lb-preview">
            {loadError && <div className="lb-error">{loadError}</div>}
            {!loadError && isHeic && (
              <div className="lb-heic">
                <b>HEIC 無法在瀏覽器預覽</b>
                <span>iPhone 原生格式 — 請按上方下載鈕後本機開啟</span>
                <button type="button" className="btn-primary" onClick={_download}>下載</button>
              </div>
            )}
            {!loadError && isPdf && (
              <div className="lb-pdf">
                <span className="attach-pdf-badge">PDF</span>
                <b>{att.original_filename}</b>
                <span>{_formatAttachSize(att.size_bytes)}</span>
                <button type="button" className="btn-primary" onClick={_download}>下載 PDF</button>
              </div>
            )}
            {!loadError && !isHeic && !isPdf && previewUrl && (
              <img
                src={previewUrl}
                alt={att.original_filename}
                referrerPolicy="no-referrer"
              />
            )}
            {!loadError && !isHeic && !isPdf && !previewUrl && (
              <span className="attach-thumb-loading" />
            )}
          </div>
          {items.length > 1 && (
            <button
              type="button"
              className="lb-nav next"
              onClick={() => setIdx((i) => ((i == null ? 0 : i) + 1) % items.length)}
              aria-label="下一張"
            >›</button>
          )}
        </div>

        {items.length > 1 && (
          <div className="lb-strip">
            {items.map((it, i) => (
              <button
                key={it.id}
                type="button"
                className={'lb-strip-thumb ' + (i === (idx ?? 0) ? 'is-active' : '')}
                onClick={() => setIdx(i)}
                title={it.original_filename}
              >
                {it.mime_type === 'application/pdf'
                  ? <span className="attach-pdf-badge">PDF</span>
                  : <LightboxStripThumb att={it} />}
              </button>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

// Tiny separate component so each strip thumb gets its own presigned URL
// fetch lifecycle without entangling state in the parent grid.
function LightboxStripThumb({ att }) {
  const api = window.LEDGER_API || {};
  const [url, setUrl] = useState(null);
  const isHeic = (att.mime_type || '').includes('heic') || (att.mime_type || '').includes('heif');
  useEffect(() => {
    if (isHeic) return;
    let cancelled = false;
    api.apiAttachmentUrl(att.id, 'inline').then((resp) => {
      if (!cancelled && resp && resp.url) setUrl(resp.url);
    }).catch(() => { /* placeholder remains */ });
    return () => { cancelled = true; };
  }, [att.id, isHeic]);
  if (isHeic) return <span className="lb-strip-heic">HEIC</span>;
  if (!url) return <span className="attach-thumb-loading" />;
  return <img src={url} alt="" referrerPolicy="no-referrer" />;
}


const AttachmentsSection = forwardRef(function AttachmentsSection({ recordId, editable }, ref) {
  const api = window.LEDGER_API || {};
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(true);
  const [disabled, setDisabled] = useState(false);
  const [uploads, setUploads] = useState([]); // {tempId, name, progress, error, xhr}
  const [error, setError] = useState('');
  const fileInputRef = useRef(null);
  // IDs uploaded during the current edit-mode session. When the user clicks
  // 取消, the parent calls `cleanupPending()` (exposed via the imperative
  // ref below) to soft-delete each so attachments follow the same
  // "discard-on-cancel" mental model as the rest of the edit form. On 儲存
  // they stay — the upload already wrote them to DB, and the save action
  // implicitly commits them by clearing this tracking on the next
  // edit→view transition.
  const [pendingIds, setPendingIds] = useState(() => new Set());
  // Tracks pendingIds for the ref handle so `cleanupPending` doesn't need
  // pendingIds in its deps (would otherwise stale-closure on the user's
  // first rapid cancel after upload).
  const pendingIdsRef = useRef(pendingIds);
  pendingIdsRef.current = pendingIds;
  // The recordId an in-flight upload / list started with. Switching records
  // mid-upload would otherwise let response A land in record B's panel
  // (codex round 2). Updated during render — a passive useEffect would
  // leave a committed-render gap where the ref still points at the OLD
  // recordId while React has already rendered with the NEW one, and an
  // XHR callback firing in that window would mutate the wrong panel.
  // Ref mutation during render is React-blessed for "track last value"
  // patterns; we guard with an equality check so it stays idempotent.
  const recordRef = useRef(recordId);
  if (recordRef.current !== recordId) {
    recordRef.current = recordId;
  }
  // Tracks every in-flight XHR + pending setTimeout so cleanup can abort
  // them on unmount. The state-based uploads array also has xhr handles,
  // but unmount happens BEFORE the next render so we can't read state in
  // cleanup — refs survive across renders.
  const activeXhrsRef = useRef(new Set());
  const pendingTimersRef = useRef(new Set());

  // Skip the fetch entirely when we don't have a real record id yet —
  // brand-new records render an editable form before they hit the DB.
  useEffect(() => {
    if (!recordId) {
      setItems([]);
      setUploads([]);
      setError('');
      setLoading(false);
      setPendingIds(new Set());
      return;
    }
    let cancelled = false;
    setLoading(true);
    setError('');
    setItems([]);
    setUploads([]);
    // Fresh fetch = fresh edit session: anything from a prior session is
    // either already committed (via save) or already deleted (via cancel).
    setPendingIds(new Set());
    api.apiListRecordAttachments(recordId).then((resp) => {
      if (cancelled || recordRef.current !== recordId) return;
      if (resp && resp.disabled) {
        setDisabled(true);
        setItems([]);
      } else {
        setItems((resp && resp.attachments) || []);
      }
      setLoading(false);
    }).catch((err) => {
      if (cancelled || recordRef.current !== recordId) return;
      setError(err && err.message || '載入附件失敗');
      setLoading(false);
    });
    return () => {
      cancelled = true;
      // On both recordId switch AND unmount: abort outstanding uploads and
      // drop any pending auto-dismiss timeouts. Without this, an XHR that
      // resolves after unmount would call setUploads on a dead component
      // (warning + memory leak), and a setTimeout would fire long after.
      for (const xhr of activeXhrsRef.current) {
        try { xhr.abort(); } catch { /* noop */ }
      }
      activeXhrsRef.current = new Set();
      for (const t of pendingTimersRef.current) {
        clearTimeout(t);
      }
      pendingTimersRef.current = new Set();
    };
  }, [recordId]);

  if (disabled) return null;       // feature flag off — UI vanishes
  if (!recordId && !editable) return null;
  if (!recordId && editable) {
    return (
      <div className="dv-section attach-section">
        <div className="dv-section-head">
          <div className="dv-section-eyebrow">附件</div>
        </div>
        <div className="attach-empty-hint">先儲存這筆紀錄後即可上傳附件</div>
      </div>
    );
  }

  function _validateFiles(files) {
    const ok = [];
    for (const f of files) {
      if (f.size > ATTACH_MAX_BYTES) {
        setError(f.name + ' 超過 10MB，跳過');
        continue;
      }
      const mime = f.type || '';
      const passes = ATTACH_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p));
      if (mime && !passes) {
        setError(f.name + ' 類型不支援，跳過');
        continue;
      }
      ok.push(f);
    }
    return ok;
  }

  async function _uploadOne(file) {
    const tempId = 'up-' + Math.random().toString(36).slice(2);
    // Capture the record at upload-start time. If the user switches records
    // before the response lands we leave the row alone (it correctly belongs
    // to the original record) but skip touching this component's state for
    // the new record.
    const startedFor = recordId;
    const { promise, xhr } = api.apiUploadAttachment(
      recordId, file,
      (pct) => {
        if (recordRef.current !== startedFor) return;
        setUploads((u) => u.map((it) => it.tempId === tempId ? { ...it, progress: pct } : it));
      },
    );
    activeXhrsRef.current.add(xhr);
    setUploads((u) => [...u, { tempId, name: file.name, progress: 0, xhr }]);
    try {
      const row = await promise;
      activeXhrsRef.current.delete(xhr);
      if (recordRef.current !== startedFor) return;
      setItems((arr) => [...arr, row]);
      // Mark this row as pending so a subsequent 取消 click rolls it back.
      // We only track when editable=true; uploads from view mode (which
      // shouldn't happen via the UI anyway) wouldn't be cancellable.
      if (editable) {
        setPendingIds((s) => {
          const n = new Set(s);
          n.add(row.id);
          return n;
        });
      }
    } catch (err) {
      activeXhrsRef.current.delete(xhr);
      if (recordRef.current !== startedFor) return;
      const msg = err && err.message;
      if (msg === 'aborted') {
        setUploads((u) => u.filter((it) => it.tempId !== tempId));
        return;
      }
      setUploads((u) => u.map((it) => it.tempId === tempId ? { ...it, error: msg || '上傳失敗' } : it));
      // Track the auto-dismiss timer so unmount-cleanup can drop it instead
      // of firing a setUploads on a dead component.
      const timer = setTimeout(() => {
        pendingTimersRef.current.delete(timer);
        if (recordRef.current !== startedFor) return;
        setUploads((u) => u.filter((it) => it.tempId !== tempId));
      }, 4000);
      pendingTimersRef.current.add(timer);
      return;
    }
    if (recordRef.current !== startedFor) return;
    setUploads((u) => u.filter((it) => it.tempId !== tempId));
  }

  async function _onPick(e) {
    setError('');
    const list = _validateFiles(Array.from(e.target.files || []));
    e.target.value = '';
    for (const f of list) await _uploadOne(f);
  }

  // Open the in-app lightbox modal instead of a new tab — gives the user
  // prev/next nav, a real preview (not browser-download), and a separate
  // download button that uses Content-Disposition: attachment.
  const [lightboxIdx, setLightboxIdx] = useState(null);
  function _open(att) {
    const i = items.findIndex((x) => x.id === att.id);
    if (i >= 0) setLightboxIdx(i);
  }
  function _forgetPending(id) {
    setPendingIds((s) => {
      if (!s.has(id)) return s;
      const n = new Set(s);
      n.delete(id);
      return n;
    });
  }

  async function _deleteFromLightbox(att) {
    setLightboxIdx(null);
    try {
      const ok = await api.apiDeleteAttachment(att.id);
      if (ok) {
        setItems((arr) => arr.filter((x) => x.id !== att.id));
        _forgetPending(att.id);
      }
    } catch (err) {
      setError(err && err.message || '刪除失敗');
    }
  }

  async function _remove(att) {
    if (!window.confirm('刪除附件「' + (att.original_filename || '此檔') + '」？')) return;
    try {
      const ok = await api.apiDeleteAttachment(att.id);
      if (ok) {
        setItems((arr) => arr.filter((x) => x.id !== att.id));
        _forgetPending(att.id);
      }
    } catch (err) {
      setError(err && err.message || '刪除失敗');
    }
  }

  // Exposed to DetailPanel via ref. Cancel button calls this BEFORE flipping
  // back to view mode so each uploaded-this-session attachment is deleted
  // server-side, mirroring the rest of the edit form's discard semantics.
  useImperativeHandle(ref, () => ({
    async cleanupPending() {
      const ids = Array.from(pendingIdsRef.current);
      if (ids.length === 0) return;
      // Best effort — a single failed delete must not block the rest.
      // The GC orphan scan will reclaim any stragglers after the grace
      // window anyway.
      await Promise.all(ids.map((id) => api.apiDeleteAttachment(id).catch(() => null)));
      setItems((arr) => arr.filter((x) => !pendingIdsRef.current.has(x.id)));
      setPendingIds(new Set());
    },
    hasPending() {
      return pendingIdsRef.current.size > 0;
    },
  }), [api]);

  const hasContent = items.length > 0 || uploads.length > 0;
  if (!editable && !hasContent) return null;

  return (
    <div className="dv-section attach-section">
      <div className="dv-section-head">
        <div className="dv-section-eyebrow">附件</div>
        {items.length > 0 && (
          <div className="dv-section-meta">{items.length} 個</div>
        )}
      </div>
      {loading && <div className="attach-empty-hint">載入中…</div>}
      {!loading && hasContent && (
        <ul className="attach-grid">
          {items.map((att) => (
            <li key={att.id} className={'attach-thumb ' + (att.mime_type === 'application/pdf' ? 'is-pdf' : '')}>
              <button
                type="button"
                className="attach-thumb-open"
                onClick={() => _open(att)}
                title={att.original_filename}
              >
                {att.mime_type === 'application/pdf'
                  ? (<><span className="attach-pdf-badge">PDF</span><span className="attach-pdf-name">{att.original_filename}</span></>)
                  : (<AttachmentPreview att={att} />)}
              </button>
              <div className="attach-meta-strip">
                <span className="attach-meta-name">{att.original_filename}</span>
                <span className="attach-meta-size">{_formatAttachSize(att.size_bytes)}</span>
              </div>
              {editable && (
                <button type="button" className="attach-thumb-x" onClick={() => _remove(att)} aria-label="移除附件">✕</button>
              )}
            </li>
          ))}
          {uploads.map((u) => (
            <li key={u.tempId} className="attach-thumb is-uploading">
              <div className="attach-thumb-uploading">{u.name}</div>
              {u.error
                ? <div className="attach-progress-error">{u.error}</div>
                : <div className="attach-progress"><i style={{ width: u.progress + '%' }} /></div>}
            </li>
          ))}
        </ul>
      )}
      {!loading && !hasContent && editable && (
        <div className="attach-empty-hint">尚未上傳任何附件</div>
      )}
      {editable && (
        <div className="attach-upload-row">
          <button
            type="button"
            className="btn-utility"
            onClick={() => fileInputRef.current && fileInputRef.current.click()}
          >📎 上傳附件</button>
          <input
            ref={fileInputRef}
            type="file"
            accept={ATTACH_ACCEPT}
            multiple
            style={{ display: 'none' }}
            onChange={_onPick}
          />
          <span className="attach-upload-hint">單檔 ≤ 10MB · 圖片或 PDF</span>
        </div>
      )}
      {error && <div className="attach-error">{error}</div>}
      <AttachmentLightbox
        items={items}
        openIndex={lightboxIdx}
        editable={editable}
        onClose={() => setLightboxIdx(null)}
        onDelete={_deleteFromLightbox}
      />
    </div>
  );
});

// Inline grid-thumbnail preview. Fetches an inline-disposition presigned
// URL on mount (a previous bug used the default attachment-disposition URL,
// which made browsers download the bytes instead of rendering an <img>).
//
// Diagnostics in this component are deliberately visible — first prod
// smoke shipped with a silent failure mode (presigned URL fetch failed,
// component sat in a spinner state, user just saw an empty tile with
// the filename strip below). Surface both fetch errors and <img> load
// errors so the next regression is debuggable from the UI alone.
function AttachmentPreview({ att }) {
  const api = window.LEDGER_API || {};
  const [url, setUrl] = useState(null);
  const [err, setErr] = useState('');
  const isHeic = (att.mime_type || '').includes('heic') || (att.mime_type || '').includes('heif');
  useEffect(() => {
    if (isHeic) return;
    let cancelled = false;
    setUrl(null);
    setErr('');
    api.apiAttachmentUrl(att.id, 'inline').then((resp) => {
      if (cancelled) return;
      if (resp && resp.url) setUrl(resp.url);
      else setErr('簽名失敗');
    }).catch((e) => {
      if (!cancelled) setErr((e && e.message) || '簽名失敗');
    });
    return () => { cancelled = true; };
  }, [att.id, isHeic]);
  if (isHeic) {
    return <span className="attach-heic">HEIC<br/>點擊開啟</span>;
  }
  if (err) return <span className="attach-thumb-err" title={err}>⚠</span>;
  if (!url) return <span className="attach-thumb-loading" />;
  // Notable details:
  //   - NO loading="lazy". First-prod-smoke hit a case where the lazy
  //     intersection observer never fired inside the square-aspect-ratio
  //     container; the image stayed dormant and the tile looked empty.
  //     Attachment grids are short enough (few per record) that lazy load
  //     buys nothing and breaks correctness.
  //   - referrerPolicy="no-referrer" strips the app origin from the GET.
  //   - onError surfaces R2 / network failures so users + future devs
  //     see "broken image" instead of a silent empty box.
  return (
    <img
      src={url}
      alt={att.original_filename}
      referrerPolicy="no-referrer"
      onError={() => setErr('圖片載入失敗')}
    />
  );
}

// ──────────────────────────────────────────────────────────────────────────────

function DetailPanel({ tx, data, onClose, onSave, onDelete, onDuplicate, saving, projectsCatalog }) {
  const [mode, setMode] = useState('view'); // 'view' | 'edit'
  // Imperative handle to the edit-mode AttachmentsSection so the panel's
  // 取消 button can soft-delete any attachments uploaded this session.
  const editAttachmentsRef = useRef(null);
  const [cancelling, setCancelling] = useState(false);
  const [draftState, setDraftState] = useState(tx);
  const [syncedId, setSyncedId] = useState(tx?.id);
  const [kbOpen, setKbOpen] = useState(false);

  // Mobile keyboard detection via visualViewport. When the soft keyboard pops
  // up, the visual viewport shrinks while window.innerHeight stays put.
  // We compare the two; >150px gap = keyboard. Bottom-sheet panel switches to
  // full-screen via .panel--full so form fields don't get clipped.
  useEffect(() => {
    const vv = window.visualViewport;
    if (!vv) return;
    const onResize = () => setKbOpen(window.innerHeight - vv.height > 150);
    vv.addEventListener('resize', onResize);
    onResize();
    return () => vv.removeEventListener('resize', onResize);
  }, []);

  // Reset draft inline when the panel switches to a new tx (React docs pattern:
  // "resetting state from props"). This avoids the useEffect-lag where draft
  // would be null/stale on the first render after `tx` becomes non-null.
  if (tx && syncedId !== tx.id) {
    setSyncedId(tx.id);
    setDraftState(tx);
    setMode(tx._isNew ? 'edit' : 'view');
  }
  const draft = draftState || tx;
  const setDraft = setDraftState;

  // Context derivations — must be called unconditionally (Rules of Hooks).
  // We pass safe fallbacks when tx is null.
  const sameName = useMemo(() => {
    if (!tx?.name) return [];
    return data
      .filter((t) => t.name === tx.name && t.id !== tx.id)
      .sort((a, b) => b.date - a.date);
  }, [tx?.id, tx?.name, data]);

  const sameSubThisMonth = useMemo(() => {
    if (!tx) return 0;
    return data
      .filter((t) => t.categoryId === tx.categoryId && t.sub === tx.sub && F.sameMonth(t.date, tx.date))
      .reduce((s, t) => s + t.amount, 0);
  }, [tx?.id, tx?.categoryId, tx?.sub, tx?.date, data]);

  const subAvgAmount = useMemo(() => {
    if (!tx) return 0;
    const arr = data.filter((t) => t.categoryId === tx.categoryId && t.sub === tx.sub);
    if (arr.length === 0) return 0;
    return arr.reduce((s, t) => s + t.amount, 0) / arr.length;
  }, [tx?.categoryId, tx?.sub, data]);

  // 12-month frequency sparkline of this sub-category
  const subSpark = useMemo(() => {
    if (!tx) return [];
    const out = [];
    for (let i = 11; i >= 0; i--) {
      const m = new Date(TODAY.getFullYear(), TODAY.getMonth() - i, 1);
      const next = new Date(m.getFullYear(), m.getMonth() + 1, 1);
      const total = data
        .filter((t) => t.categoryId === tx.categoryId && t.sub === tx.sub && t.date >= m && t.date < next)
        .reduce((s, t) => s + t.amount, 0);
      out.push({ month: m, total });
    }
    return out;
  }, [tx?.categoryId, tx?.sub, data]);

  if (!tx) return null;
  const cat = CATEGORIES.find((c) => c.id === draft.categoryId);

  const vsAvg = subAvgAmount > 0 ? Math.round(((tx.amount - subAvgAmount) / subAvgAmount) * 100) : 0;

  // Compute "days since last same-name" without mutating tx.date
  const lastSameDays = sameName[0]
    ? Math.round(
        (new Date(tx.date.getFullYear(), tx.date.getMonth(), tx.date.getDate()).getTime() -
         new Date(sameName[0].date.getFullYear(), sameName[0].date.getMonth(), sameName[0].date.getDate()).getTime())
        / 86400000
      )
    : null;

  const sameNameAvg = sameName.length > 0
    ? Math.round(sameName.reduce((s, t) => s + t.amount, 0) / sameName.length)
    : null;

  // ─── View mode ───────────────────────────────────────────────────────
  if (mode === 'view') {
    return (
      <>
        <div className="panel-scrim" onClick={onClose} />
        <aside className={'panel ' + (kbOpen ? 'panel--full' : '')} role="dialog" aria-label="交易明細">
          <header className="panel-head">
            <button className="panel-x" onClick={onClose} type="button" aria-label="關閉">✕</button>
            <div className="panel-eyebrow">交易明細</div>
            <div className="panel-actions">
              <button className="btn-utility-ghost" onClick={() => onDuplicate(draft)} type="button" title="複製到今天" disabled={saving}>
                <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true">
                  <rect x="3" y="3" width="9" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.3" fill="none"/>
                  <path d="M5.5 5.5h-1A1.5 1.5 0 0 0 3 7v7a1.5 1.5 0 0 0 1.5 1.5H10A1.5 1.5 0 0 0 11.5 14v-1" stroke="currentColor" strokeWidth="1.3" fill="none"/>
                </svg>
              </button>
              <button className="btn-utility-ghost btn-edit" onClick={() => setMode('edit')} type="button">
                <svg width="12" height="12" viewBox="0 0 16 16" aria-hidden="true">
                  <path d="M11.5 2.5l2 2-8 8H3.5v-2l8-8z" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinejoin="round"/>
                </svg>
                編輯
              </button>
            </div>
          </header>

          {/* Hero */}
          <div className="dv-hero">
            <div className={'dv-cat-badge tx-cat-' + tx.categorySlug}>
              <span className="cat-dot" />
              {tx.category} <span className="dv-sub">／{tx.sub}</span>
            </div>
            {Array.isArray(tx.projectIds) && tx.projectIds.length > 0 && projectsCatalog && (
              <div className="dv-projects" style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
                {tx.projectIds.map((id) => {
                  const p = projectsCatalog.find((q) => q.id === id);
                  if (!p) return null;
                  return (
                    <span key={id} className="pp-chip" style={p.color ? { ['--proj-color']: p.color } : null}>
                      <span className="pp-chip-dot" style={p.color ? { background: p.color } : null} />
                      {p.name}
                    </span>
                  );
                })}
              </div>
            )}
            <div className="dv-name">{tx.name || <span className="dv-name-empty">未命名</span>}</div>
            <div className="dv-amt">
              <span className="dv-amt-mark">NT$</span>
              <span className="dv-amt-num">{tx.amount.toLocaleString('en-US')}</span>
            </div>
            <div className="dv-when">
              <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true">
                <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.3" fill="none"/>
                <path d="M8 4.5V8l2.5 1.5" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round"/>
              </svg>
              {F.longDate(tx.date)}
              <span className="dv-dot">·</span>
              <span className="dv-rel">{F.relativeDay(tx.date, TODAY)}</span>
              {tx.time && (
                <>
                  <span className="dv-dot">·</span>
                  <span className="dv-time">{tx.time}</span>
                </>
              )}
            </div>
            {tx.account && (
              <div className="dv-acct">
                <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true">
                  <rect x="2" y="4" width="12" height="8" rx="1.5" stroke="currentColor" strokeWidth="1.3" fill="none"/>
                  <path d="M2 7h12" stroke="currentColor" strokeWidth="1.3"/>
                </svg>
                {tx.account}
              </div>
            )}
            {tx.currencyOriginal && tx.amountOriginal != null && (
              <div className="dv-fx">
                {Number(tx.amountOriginal).toLocaleString('en-US')} {tx.currencyOriginal}
                {' → '}NT$ {Number(tx.amount).toLocaleString('en-US')}
                {tx.fxRate != null && (<> {' @ '}{Number(tx.fxRate)}</>)}
              </div>
            )}
          </div>

          {/* Note (if present) */}
          {tx.note && (
            <div className="dv-note">
              <div className="dv-note-label">備註</div>
              <div className="dv-note-body">{tx.note}</div>
            </div>
          )}

          {/* M4 attachments — read-only grid in view mode */}
          {!tx._isNew && (
            <AttachmentsSection recordId={tx.id} editable={false} />
          )}

          {/* Insight stats */}
          <div className="dv-stats">
            <div className="dv-stat">
              <div className="dv-stat-k">上次同名</div>
              <div className="dv-stat-v">
                {lastSameDays != null
                  ? (lastSameDays === 0 ? '今天稍早' : lastSameDays + ' 天前')
                  : '首次'}
              </div>
              {sameName[0] && <div className="dv-stat-sub">NT$ {sameName[0].amount.toLocaleString('en-US')}</div>}
            </div>
            <div className="dv-stat">
              <div className="dv-stat-k">同名平均</div>
              <div className="dv-stat-v">
                {sameNameAvg != null ? 'NT$ ' + sameNameAvg.toLocaleString('en-US') : '—'}
              </div>
              <div className="dv-stat-sub">{sameName.length > 0 ? '共 ' + (sameName.length + 1) + ' 次' : '只有這一筆'}</div>
            </div>
            <div className="dv-stat">
              <div className="dv-stat-k">本月此子分類</div>
              <div className="dv-stat-v">NT$ {sameSubThisMonth.toLocaleString('en-US')}</div>
              <div className="dv-stat-sub">{tx.sub} · {(tx.date.getMonth() + 1)}月</div>
            </div>
            <div className="dv-stat">
              <div className="dv-stat-k">vs 子分類均</div>
              <div className={'dv-stat-v ' + (vsAvg > 10 ? 'dv-stat-up' : vsAvg < -10 ? 'dv-stat-down' : '')}>
                {vsAvg > 0 ? '+' : ''}{vsAvg}%
              </div>
              <div className="dv-stat-sub">均 NT$ {Math.round(subAvgAmount).toLocaleString('en-US')}</div>
            </div>
          </div>

          {/* Sub-category 12-month spark */}
          <div className="dv-section">
            <div className="dv-section-head">
              <div className="dv-section-eyebrow">{tx.sub} · 近 12 個月趨勢</div>
            </div>
            <Sparkline data={subSpark} slug={tx.categorySlug} />
          </div>

          {/* Recent same-name */}
          {sameName.length > 0 && (
            <div className="dv-section">
              <div className="dv-section-head">
                <div className="dv-section-eyebrow">同名最近紀錄</div>
                <div className="dv-section-meta">{sameName.length} 筆</div>
              </div>
              <ul className="dv-similar">
                {sameName.slice(0, 5).map((s) => (
                  <li key={s.id} className="dv-similar-row">
                    <span className="dv-similar-date">
                      {s.date.getFullYear() === TODAY.getFullYear()
                        ? (s.date.getMonth() + 1) + '/' + s.date.getDate()
                        : s.date.getFullYear() + '/' + (s.date.getMonth() + 1) + '/' + s.date.getDate()}
                    </span>
                    <span className={'dv-similar-sub tx-cat-' + s.categorySlug}>
                      <span className="cat-dot" />
                      {s.sub}
                    </span>
                    <span className="dv-similar-amt">NT$ {s.amount.toLocaleString('en-US')}</span>
                  </li>
                ))}
              </ul>
            </div>
          )}

          <footer className="panel-foot">
            <button className="btn-text-danger" type="button" onClick={() => onDelete(tx.id)}>刪除這筆</button>
            <div className="spacer" />
            <button className="btn-utility" type="button" onClick={onClose}>關閉</button>
            <button className="btn-primary" type="button" onClick={() => setMode('edit')}>編輯</button>
          </footer>
        </aside>
      </>
    );
  }

  // ─── Edit mode ───────────────────────────────────────────────────────
  // Close via scrim / X button: same rollback semantics as the 取消 button.
  // Without this, clicking outside the panel after an upload would silently
  // commit the attachment despite the rest of the draft being discarded.
  const closeEditWithCleanup = async () => {
    if (cancelling) return;
    setCancelling(true);
    try {
      if (editAttachmentsRef.current) {
        await editAttachmentsRef.current.cleanupPending();
      }
    } finally {
      setCancelling(false);
    }
    onClose();
  };
  return (
    <>
      <div className="panel-scrim" onClick={closeEditWithCleanup} />
      <aside className="panel" role="dialog" aria-label="編輯交易">
        <header className="panel-head">
          <button className="panel-x" onClick={closeEditWithCleanup} type="button" aria-label="關閉">✕</button>
          <div className="panel-eyebrow">編輯交易</div>
          <div className="panel-actions">
            {!tx._isNew && (
              <button className="btn-utility-ghost" onClick={() => setMode('view')} type="button">取消編輯</button>
            )}
            <button className="btn-utility-ghost" onClick={() => onDuplicate(draft)} type="button" disabled={saving}>複製</button>
          </div>
        </header>

        <div className="panel-amount">
          <div className="panel-amt-mark">NT$</div>
          <input
            className="panel-amt-input"
            value={draft.amount}
            onChange={(e) => setDraft({ ...draft, amount: Number(e.target.value.replace(/[^0-9]/g, '')) || 0 })}
            inputMode="numeric"
          />
        </div>
        <div className="panel-amt-day">
          {F.longDate(draft.date)}
        </div>

        <div className="panel-fields">
          <div className="field">
            <label>項目名稱</label>
            <input
              value={draft.name || ''}
              onChange={(e) => setDraft({ ...draft, name: e.target.value })}
              placeholder="例：鬍鬚張 排骨飯"
              className="field-input-name"
              autoFocus
            />
          </div>
          <div className="field">
            <label>分類</label>
            <div className="field-pills">
              {CATEGORIES.map((c) => (
                <button
                  key={c.id}
                  type="button"
                  className={'pill ' + (draft.categoryId === c.id ? 'pill-on' : '')}
                  onClick={() => setDraft({ ...draft, categoryId: c.id, category: c.name, sub: c.sub[0] })}
                >{c.name}</button>
              ))}
            </div>
          </div>
          <div className="field">
            <label>子分類</label>
            <div className="field-pills">
              {cat.sub.map((s) => (
                <button
                  key={s}
                  type="button"
                  className={'pill pill-sub ' + (draft.sub === s ? 'pill-on' : '')}
                  onClick={() => setDraft({ ...draft, sub: s })}
                >{s}</button>
              ))}
            </div>
          </div>
          <div className="field">
            <label>日期</label>
            <input
              type="date"
              value={F.dateKey(draft.date)}
              onChange={(e) => {
                const [y, m, d] = e.target.value.split('-').map(Number);
                const nd = new Date(draft.date);
                nd.setFullYear(y, m - 1, d);
                setDraft({ ...draft, date: nd });
              }}
            />
          </div>
          <div className="field">
            <label>時間（選填）</label>
            <input
              type="time"
              value={draft.time || ''}
              onChange={(e) => setDraft({ ...draft, time: e.target.value })}
            />
          </div>
          <div className="field">
            <label>備註（選填）</label>
            <input
              value={draft.note || ''}
              onChange={(e) => setDraft({ ...draft, note: e.target.value })}
              placeholder="加一句話備註…"
            />
          </div>
          <div className="field">
            <label>支付方式（選填）</label>
            <input
              value={draft.account || ''}
              onChange={(e) => setDraft({ ...draft, account: e.target.value })}
              placeholder="信用卡 / 現金 / 悠遊卡…"
              list="payment-method-suggestions"
            />
            <datalist id="payment-method-suggestions">
              <option value="信用卡" />
              <option value="現金" />
              <option value="悠遊卡" />
              <option value="Apple Pay" />
              <option value="LINE Pay" />
            </datalist>
          </div>
          <ForeignCurrencyField draft={draft} setDraft={setDraft} />
          {window.Projects && window.Projects.ProjectPicker && (
            <div className="field">
              <label>所屬專案（選填）</label>
              <window.Projects.ProjectPicker
                value={draft.projectIds || []}
                onChange={(ids) => setDraft({ ...draft, projectIds: ids })}
                projects={projectsCatalog || []}
              />
            </div>
          )}

          {/* M4 attachments — only available after the record exists */}
          <AttachmentsSection
            recordId={tx._isNew ? null : tx.id}
            editable={true}
            ref={editAttachmentsRef}
          />
        </div>

        <footer className="panel-foot">
          <button className="btn-text-danger" type="button" onClick={() => onDelete(tx.id)}>刪除這筆</button>
          <div className="spacer" />
          <button
            className="btn-utility"
            type="button"
            disabled={cancelling}
            onClick={async () => {
              // Attachments uploaded since entering edit mode get
              // soft-deleted on cancel so they don't survive a flow the
              // user explicitly aborted. Save commits them implicitly
              // (the upload already wrote them to DB; AttachmentsSection
              // clears the pending tracking when editable flips off).
              setCancelling(true);
              try {
                if (editAttachmentsRef.current) {
                  await editAttachmentsRef.current.cleanupPending();
                }
              } finally {
                setCancelling(false);
              }
              if (tx._isNew) onClose();
              else setMode('view');
            }}
          >{cancelling ? '取消中…' : '取消'}</button>
          <button
            className="btn-primary"
            type="button"
            onClick={() => onSave(draft)}
            disabled={saving || cancelling}
          >{saving ? '儲存中…' : '儲存'}</button>
        </footer>
      </aside>
    </>
  );
}

// ──────────────────────────────────────────────────────────────────────────────
// ConfirmDialog — reusable destructive-action confirmation

function ConfirmDialog({ title, body, danger, confirmLabel, cancelLabel, onConfirm, onClose }) {
  useEffect(() => {
    const onKey = (e) => {
      if (e.key === 'Escape') onClose();
      if (e.key === 'Enter') { onConfirm(); onClose(); }
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [onClose, onConfirm]);

  return (
    <>
      <div className="confirm-scrim" onClick={onClose} />
      <div className="confirm-dialog" role="dialog" aria-modal="true">
        <div className="confirm-body">
          <h3 className="confirm-title">{title}</h3>
          {body && <p className="confirm-text">{body}</p>}
        </div>
        <div className="confirm-foot">
          <button type="button" className="btn-utility" onClick={onClose}>
            {cancelLabel || '取消'}
          </button>
          <button
            type="button"
            className={danger ? 'btn-confirm-danger' : 'btn-primary'}
            onClick={() => { onConfirm(); onClose(); }}
          >
            {confirmLabel || '確認'}
          </button>
        </div>
      </div>
    </>
  );
}

// ──────────────────────────────────────────────────────────────────────────────
// ManageCategoriesModal — full CRUD for categories and sub-categories.
//
// API-backed (M4): owns its own state copy fetched from /api/categories/full.
// Internal shape is { id: <db_id:int>, name, color, slug, sort_order,
// sub: [{ id, name, sort_order }] } — distinct from the legacy "id = name"
// shape used elsewhere in app.jsx. On close, ``onChanged`` is fired so the
// parent can re-bootstrap its own ``cats``.
//
// Mutations are API-first: the server call must succeed before local state
// updates. Failures surface as a toast banner; nothing is rolled back because
// no local mutation happened yet.

function ManageCategoriesModal({ data, onClose, onConfirm, onChanged }) {
  const api = window.LEDGER_API;
  const [localCats, setLocalCats] = useState([]);
  const [loading, setLoading] = useState(true);
  const [errorMsg, setErrorMsg] = useState('');
  const [expanded, setExpanded] = useState(null);
  const [selectedCatIds, setSelectedCatIds] = useState(() => new Set());
  const [selectedSubs, setSelectedSubs] = useState({}); // {catId: Set<subId>}
  const [addingCat, setAddingCat] = useState(false);
  const [newCatName, setNewCatName] = useState('');
  const [newCatColor, setNewCatColor] = useState('#0066cc');
  const [addingSubFor, setAddingSubFor] = useState(null);
  const [newSubName, setNewSubName] = useState('');
  const [newCatSlug, setNewCatSlug] = useState('');
  // {catId: typedSlug} — same draft pattern as renameDraft so a failed
  // PATCH (422 on bad chars) doesn't strand the input on an unsaved value.
  const [slugDraft, setSlugDraft] = useState({});
  const dirtyRef = useRef(false);

  // Mirror server-side _SLUG_RE — lowercase a-z0-9 plus dashes, must start
  // with alphanumeric. Empty string also OK (= clear palette assignment).
  const normalizeSlug = (s) => String(s || '')
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9-]+/g, '-')
    .replace(/^-+/, '')
    .slice(0, 32);

  // ── Initial fetch
  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const fresh = await api.apiCategoriesFull();
        if (cancelled) return;
        setLocalCats(fresh);
        setExpanded(fresh[0]?.id || null);
      } catch (err) {
        if (!cancelled) setErrorMsg(err.message || '載入分類失敗');
      } finally {
        if (!cancelled) setLoading(false);
      }
    })();
    return () => { cancelled = true; };
  }, [api]);

  // Notify parent on unmount if anything mutated, so it can refresh its own cats.
  useEffect(() => () => {
    if (dirtyRef.current && typeof onChanged === 'function') onChanged();
  }, [onChanged]);

  useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [onClose]);

  // ── Stats helpers (tx counts use transaction.categoryId = NAME, not dbId)
  const txCountByCatName = useMemo(() => {
    const m = {};
    data.forEach((t) => { m[t.categoryId] = (m[t.categoryId] || 0) + 1; });
    return m;
  }, [data]);
  const txCountBySubKey = useMemo(() => {
    const m = {};
    data.forEach((t) => {
      const k = t.categoryId + '::' + t.sub;
      m[k] = (m[k] || 0) + 1;
    });
    return m;
  }, [data]);

  // ── Multi-select helpers
  const toggleCat = (id) => {
    setSelectedCatIds((cur) => {
      const next = new Set(cur);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });
  };
  const toggleSub = (catId, subId) => {
    setSelectedSubs((cur) => {
      const set = new Set(cur[catId] || []);
      set.has(subId) ? set.delete(subId) : set.add(subId);
      return { ...cur, [catId]: set };
    });
  };

  const totalSubsSelected = Object.values(selectedSubs).reduce((s, set) => s + (set?.size || 0), 0);
  const bulkCount = selectedCatIds.size + totalSubsSelected;

  const runOrToast = async (fn, fallbackMsg) => {
    try {
      await fn();
      dirtyRef.current = true;
      setErrorMsg('');
    } catch (err) {
      setErrorMsg(err.message || fallbackMsg);
    }
  };

  // ── Actions (each calls API first, then updates local state) ──────────

  const handleAddCat = () =>
    runOrToast(async () => {
      const name = newCatName.trim();
      if (!name) return;
      const slug = normalizeSlug(newCatSlug);
      const created = await api.apiCategoryCreate({
        name, color: newCatColor, slug, subs: ['雜支'],
      });
      setLocalCats((cur) => [...cur, created]);
      setNewCatName('');
      setNewCatSlug('');
      setAddingCat(false);
      setExpanded(created.id);
    }, '新增分類失敗');

  const setLocalCatSlug = (catId, slug) => {
    setSlugDraft((cur) => ({ ...cur, [catId]: slug }));
  };

  const commitSlug = (cat) => {
    const draftRaw = slugDraft[cat.id];
    if (draftRaw === undefined) return;
    const normalized = normalizeSlug(draftRaw);
    const clearDraft = () => setSlugDraft((cur) => {
      const { [cat.id]: _gone, ...rest } = cur;
      return rest;
    });
    if (normalized === (cat.slug || '')) {
      clearDraft();
      return;
    }
    (async () => {
      try {
        const updated = await api.apiCategoryPatch(cat.id, { slug: normalized });
        setLocalCats((cur) => cur.map((c) => c.id === cat.id
          ? { ...c, slug: updated.slug }
          : c
        ));
        dirtyRef.current = true;
        setErrorMsg('');
      } catch (err) {
        setErrorMsg(err.message || '更新顏色標籤失敗');
      } finally {
        clearDraft();
      }
    })();
  };

  const handleAddSub = (cat) =>
    runOrToast(async () => {
      const name = newSubName.trim();
      if (!name) return;
      const sub = await api.apiSubCreate(cat.id, name);
      setLocalCats((cur) => cur.map((c) => c.id === cat.id
        ? { ...c, sub: [...c.sub, sub] }
        : c
      ));
      setNewSubName('');
      setAddingSubFor(null);
    }, '新增子分類失敗');

  const doDeleteCats = async (catIds) => {
    const ids = Array.from(catIds);
    const results = await Promise.allSettled(ids.map((id) => api.apiCategoryDelete(id)));
    const succeeded = new Set();
    results.forEach((r, i) => { if (r.status === 'fulfilled') succeeded.add(ids[i]); });
    if (succeeded.size) {
      setLocalCats((cur) => cur.filter((c) => !succeeded.has(c.id)));
      dirtyRef.current = true;
    }
    const failed = ids.length - succeeded.size;
    if (failed) setErrorMsg(`${failed} 個分類刪除失敗`);
    setSelectedCatIds(new Set());
  };

  const doDeleteSubsBulk = async () => {
    const pairs = [];
    Object.entries(selectedSubs).forEach(([catId, set]) => {
      (set || []).forEach((subId) => pairs.push({ catId: Number(catId), subId }));
    });
    const results = await Promise.allSettled(pairs.map((p) => api.apiSubDelete(p.subId)));
    const succeededPerCat = {};
    results.forEach((r, i) => {
      if (r.status !== 'fulfilled') return;
      const { catId, subId } = pairs[i];
      (succeededPerCat[catId] ||= new Set()).add(subId);
    });
    if (Object.keys(succeededPerCat).length) {
      setLocalCats((cur) => cur.map((c) => {
        const removed = succeededPerCat[c.id];
        return removed ? { ...c, sub: c.sub.filter((s) => !removed.has(s.id)) } : c;
      }));
      dirtyRef.current = true;
    }
    const failed = pairs.length - results.filter((r) => r.status === 'fulfilled').length;
    if (failed) setErrorMsg(`${failed} 個子分類刪除失敗`);
    setSelectedSubs({});
  };

  const handleDeleteOneCat = (cat) => {
    const txCount = txCountByCatName[cat.name] || 0;
    onConfirm({
      title: `刪除分類「${cat.name}」？`,
      body: txCount > 0
        ? `此分類有 ${txCount} 筆既有交易；刪除後分類會從清單隱藏，但交易仍會保留。`
        : '此操作無法復原。',
      danger: true,
      confirmLabel: '刪除',
      onConfirm: () => doDeleteCats(new Set([cat.id])),
    });
  };
  const handleBulkDelete = () => {
    const catNames = localCats.filter((c) => selectedCatIds.has(c.id)).map((c) => c.name);
    const subCount = totalSubsSelected;
    const parts = [];
    if (catNames.length) parts.push(`${catNames.length} 個主分類`);
    if (subCount) parts.push(`${subCount} 個子分類`);
    onConfirm({
      title: `刪除 ${parts.join(' + ')}？`,
      body: '已連結的交易紀錄會保留，但分類會從清單隱藏。',
      danger: true,
      confirmLabel: '全部刪除',
      onConfirm: async () => {
        await doDeleteCats(selectedCatIds);
        await doDeleteSubsBulk();
      },
    });
  };

  const handleDeleteOneSub = (cat, sub) => {
    const key = cat.name + '::' + sub.name;
    const txCount = txCountBySubKey[key] || 0;
    onConfirm({
      title: `刪除子分類「${sub.name}」？`,
      body: txCount > 0
        ? `此子分類有 ${txCount} 筆既有交易；刪除後子分類會從清單隱藏，但交易仍會保留。`
        : '此操作無法復原。',
      danger: true,
      confirmLabel: '刪除',
      onConfirm: () => runOrToast(async () => {
        await api.apiSubDelete(sub.id);
        setLocalCats((cur) => cur.map((c) => c.id === cat.id
          ? { ...c, sub: c.sub.filter((s) => s.id !== sub.id) }
          : c
        ));
      }, '刪除子分類失敗'),
    });
  };

  // Rename: commit to server on blur / Enter. We keep the *draft* name in a
  // separate ref-like state so a failed rename (409 duplicate, 400 empty)
  // can restore the previous committed name instead of leaving the modal
  // showing a value the server rejected.
  const [renameDraft, setRenameDraft] = useState({}); // {catId: typedName}

  const setLocalCatName = (catId, name) => {
    setRenameDraft((cur) => ({ ...cur, [catId]: name }));
  };

  const commitRenameCat = (cat) => {
    const draftRaw = renameDraft[cat.id];
    if (draftRaw === undefined) return; // never edited
    const trimmed = draftRaw.trim();
    const clearDraft = () => setRenameDraft((cur) => {
      const { [cat.id]: _gone, ...rest } = cur;
      return rest;
    });
    if (!trimmed || trimmed === cat.name) {
      // empty / no-op: just drop the draft so the input snaps back.
      clearDraft();
      if (!trimmed) setErrorMsg('分類名稱不可為空');
      return;
    }
    (async () => {
      try {
        const updated = await api.apiCategoryPatch(cat.id, { name: trimmed });
        setLocalCats((cur) => cur.map((c) => c.id === cat.id
          ? { ...c, name: updated.name, slug: updated.slug }
          : c
        ));
        dirtyRef.current = true;
        setErrorMsg('');
      } catch (err) {
        setErrorMsg(err.message || '更新分類失敗');
      } finally {
        // Drop the draft regardless of outcome — the displayed value should
        // reflect either the freshly-renamed name or the previous committed one.
        clearDraft();
      }
    })();
  };

  const COLOR_OPTIONS = [
    '#0066cc', '#d96a3a', '#2a6fdb', '#b54a8f', '#c0a51f',
    '#3a8a6b', '#cc4747', '#7c3aed', '#7a7a7a',
  ];

  return (
    <>
      <div className="cm-scrim" onClick={onClose} />
      <aside className="cm-panel" role="dialog" aria-label="管理分類">
        <header className="cm-head">
          <button className="panel-x" onClick={onClose} type="button" aria-label="關閉">✕</button>
          <div className="panel-eyebrow">管理分類</div>
          <button
            type="button"
            className="btn-utility-ghost"
            onClick={() => { setAddingCat(true); setNewCatName(''); }}
            disabled={loading}
          >
            ＋ 新增主分類
          </button>
        </header>

        {errorMsg && (
          <div className="cm-error" role="alert">
            {errorMsg}
            <button type="button" className="link link-muted" onClick={() => setErrorMsg('')}>關閉</button>
          </div>
        )}

        <div className="cm-meta">
          {loading
            ? '載入中…'
            : `${localCats.length} 個主分類 · ${localCats.reduce((s, c) => s + c.sub.length, 0)} 個子分類`}
        </div>

        {addingCat && (
          <div className="cm-newcat-form">
            <input
              autoFocus
              value={newCatName}
              onChange={(e) => setNewCatName(e.target.value)}
              onKeyDown={(e) => { if (e.key === 'Enter') handleAddCat(); if (e.key === 'Escape') setAddingCat(false); }}
              placeholder="主分類名稱（例：寵物）"
            />
            <input
              value={newCatSlug}
              onChange={(e) => setNewCatSlug(e.target.value)}
              onBlur={(e) => setNewCatSlug(normalizeSlug(e.target.value))}
              placeholder="顏色標籤（選填，例：food / transport / pet）"
            />
            <div className="cm-color-row">
              {COLOR_OPTIONS.map((col) => (
                <button
                  key={col}
                  type="button"
                  className={'cm-color ' + (col === newCatColor ? 'cm-color-on' : '')}
                  style={{ background: col }}
                  onClick={() => setNewCatColor(col)}
                  aria-label={col}
                />
              ))}
            </div>
            <div className="cm-form-actions">
              <button type="button" className="btn-utility" onClick={() => setAddingCat(false)}>取消</button>
              <button type="button" className="btn-primary" onClick={handleAddCat}>建立</button>
            </div>
          </div>
        )}

        <ul className="cm-list">
          {localCats.map((c) => {
            const isOpen = expanded === c.id;
            const isSelected = selectedCatIds.has(c.id);
            const subSel = selectedSubs[c.id] || new Set();
            const txCount = txCountByCatName[c.name] || 0;
            return (
              <li key={c.id} className={'cm-item ' + (isSelected ? 'cm-item-selected' : '')}>
                <div className="cm-row">
                  <label className="cm-check">
                    <input
                      type="checkbox"
                      checked={isSelected}
                      onChange={() => toggleCat(c.id)}
                      aria-label={`選取 ${c.name}`}
                    />
                  </label>
                  <button
                    type="button"
                    className="cm-expand"
                    onClick={() => setExpanded(isOpen ? null : c.id)}
                    aria-expanded={isOpen}
                  >
                    <span className={'cm-dot tx-cat-' + c.slug}><span className="cat-dot" /></span>
                    <input
                      className="cm-name-input"
                      value={renameDraft[c.id] !== undefined ? renameDraft[c.id] : c.name}
                      onChange={(e) => setLocalCatName(c.id, e.target.value)}
                      onBlur={() => commitRenameCat(c)}
                      onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }}
                      onClick={(e) => e.stopPropagation()}
                    />
                    <span className="cm-count">{c.sub.length} 子 · {txCount} 筆</span>
                    <span className="cm-caret">{isOpen ? '▾' : '▸'}</span>
                  </button>
                  <button
                    type="button"
                    className="cm-delete-icon"
                    onClick={() => handleDeleteOneCat(c)}
                    aria-label={`刪除 ${c.name}`}
                    title="刪除此分類"
                  >
                    <svg width="14" height="14" viewBox="0 0 16 16">
                      <path d="M3 5h10M6 5V3.5A.5.5 0 0 1 6.5 3h3a.5.5 0 0 1 .5.5V5M5.5 5l.5 8.5h4l.5-8.5"
                        stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
                    </svg>
                  </button>
                </div>

                {isOpen && (
                  <div className="cm-subs">
                    <div className="cm-slug-row">
                      <label className="cm-slug-label">顏色標籤</label>
                      <input
                        className="cm-slug-input"
                        value={slugDraft[c.id] !== undefined ? slugDraft[c.id] : (c.slug || '')}
                        onChange={(e) => setLocalCatSlug(c.id, e.target.value)}
                        onBlur={() => commitSlug(c)}
                        onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }}
                        placeholder="food / transport / pet（選填）"
                      />
                    </div>
                    <ul className="cm-sub-list">
                      {c.sub.map((s) => {
                        const key = c.name + '::' + s.name;
                        const subTxCount = txCountBySubKey[key] || 0;
                        const isSubSel = subSel.has(s.id);
                        return (
                          <li key={s.id} className={'cm-sub ' + (isSubSel ? 'cm-sub-selected' : '')}>
                            <label className="cm-check">
                              <input
                                type="checkbox"
                                checked={isSubSel}
                                onChange={() => toggleSub(c.id, s.id)}
                                aria-label={`選取 ${s.name}`}
                              />
                            </label>
                            <span className="cm-sub-name">{s.name}</span>
                            <span className="cm-sub-count">{subTxCount} 筆</span>
                            <button
                              type="button"
                              className="cm-delete-icon"
                              onClick={() => handleDeleteOneSub(c, s)}
                              aria-label={`刪除 ${s.name}`}
                              title="刪除子分類"
                            >
                              <svg width="13" height="13" viewBox="0 0 16 16">
                                <path d="M3 5h10M6 5V3.5A.5.5 0 0 1 6.5 3h3a.5.5 0 0 1 .5.5V5M5.5 5l.5 8.5h4l.5-8.5"
                                  stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
                              </svg>
                            </button>
                          </li>
                        );
                      })}
                      {addingSubFor === c.id ? (
                        <li className="cm-sub cm-sub-new">
                          <input
                            autoFocus
                            value={newSubName}
                            onChange={(e) => setNewSubName(e.target.value)}
                            onKeyDown={(e) => {
                              if (e.key === 'Enter') handleAddSub(c);
                              if (e.key === 'Escape') { setAddingSubFor(null); setNewSubName(''); }
                            }}
                            placeholder="新子分類名稱"
                          />
                          <button type="button" className="link" onClick={() => handleAddSub(c)}>新增</button>
                          <button type="button" className="link link-muted" onClick={() => { setAddingSubFor(null); setNewSubName(''); }}>取消</button>
                        </li>
                      ) : (
                        <li className="cm-sub cm-sub-addbtn">
                          <button
                            type="button"
                            className="cm-add-sub"
                            onClick={() => { setAddingSubFor(c.id); setNewSubName(''); }}
                          >＋ 新增子分類</button>
                        </li>
                      )}
                    </ul>
                  </div>
                )}
              </li>
            );
          })}
        </ul>

        {bulkCount > 0 && (
          <div className="cm-bulk-bar">
            <span>已選 <b>{bulkCount}</b> 項</span>
            <div className="spacer" />
            <button
              type="button"
              className="btn-utility"
              onClick={() => { setSelectedCatIds(new Set()); setSelectedSubs({}); }}
            >取消選取</button>
            <button
              type="button"
              className="btn-confirm-danger"
              onClick={handleBulkDelete}
            >刪除所選</button>
          </div>
        )}
      </aside>
    </>
  );
}


// ──────────────────────────────────────────────────────────────────────────────
// Settings page

function SettingsPage({ tweaks, setTweak, budget, setBudget, budgetError, onLogout }) {
  const ACCENT_LIGHT = ['#0066cc', '#1f8a5b', '#c2410c', '#7c3aed', '#111111'];
  const ACCENT_DARK = ['#2997ff', '#30d158', '#ff9f0a', '#bf5af2', '#ff453a'];
  const accents = tweaks.dark ? ACCENT_DARK : ACCENT_LIGHT;
  return (
    <div className="settings-page">
      <div className="cats-page-head">
        <div className="cats-page-head-l">
          <div className="cats-page-eyebrow">偏好設定</div>
          <h2 className="cats-page-title">設定</h2>
          <p className="cats-page-sub">調整外觀、預算與資料管理</p>
        </div>
      </div>

      {/* Appearance */}
      <section className="sg-card">
        <div className="sg-card-head">外觀</div>
        <div className="sg-row">
          <div className="sg-row-l">
            <div className="sg-row-title">深色模式</div>
            <div className="sg-row-sub">以 Apple 風格的純黑底配色顯示</div>
          </div>
          <SettingsToggle value={tweaks.dark} onChange={(v) => setTweak('dark', v)} />
        </div>

        <div className="sg-row">
          <div className="sg-row-l">
            <div className="sg-row-title">強調色</div>
            <div className="sg-row-sub">套用到按鈕、連結與圖表色彩</div>
          </div>
          <div className="sg-swatches">
            {accents.map((c) => (
              <button
                key={c}
                type="button"
                className={'sg-swatch ' + (tweaks.accent === c ? 'sg-swatch-on' : '')}
                style={{ background: c }}
                onClick={() => setTweak('accent', c)}
                aria-label={c}
              />
            ))}
          </div>
        </div>

        <div className="sg-row">
          <div className="sg-row-l">
            <div className="sg-row-title">介面密度</div>
            <div className="sg-row-sub">影響交易列表的列高</div>
          </div>
          <div className="sg-seg">
            {['寬鬆', '一般', '緊湊'].map((d) => (
              <button
                key={d}
                type="button"
                className={'sg-seg-btn ' + (tweaks.density === d ? 'sg-seg-btn-on' : '')}
                onClick={() => setTweak('density', d)}
              >{d}</button>
            ))}
          </div>
        </div>

        <div className="sg-row">
          <div className="sg-row-l">
            <div className="sg-row-title">日曆熱度色塊</div>
            <div className="sg-row-sub">用顏色深淺呈現每日花費</div>
          </div>
          <SettingsToggle value={tweaks.heatmap} onChange={(v) => setTweak('heatmap', v)} />
        </div>

        <div className="sg-row sg-row-last">
          <div className="sg-row-l">
            <div className="sg-row-title">隱藏小數點</div>
            <div className="sg-row-sub">金額不顯示 .00</div>
          </div>
          <SettingsToggle value={tweaks.hideCents} onChange={(v) => setTweak('hideCents', v)} />
        </div>
      </section>

      {/* Budget */}
      <section className="sg-card">
        <div className="sg-card-head">預算</div>
        <div className="sg-row sg-row-last">
          <div className="sg-row-l">
            <div className="sg-row-title">月預算</div>
            <div className="sg-row-sub">影響首頁的預算進度條與「預計月底」推算</div>
          </div>
          <div className="sg-input-wrap">
            <span className="sg-input-prefix">NT$</span>
            <input
              type="text"
              inputMode="numeric"
              className="sg-input"
              value={budget.toLocaleString('en-US')}
              onChange={(e) => {
                const n = Number(e.target.value.replace(/[^0-9]/g, '')) || 0;
                setBudget(n);
              }}
            />
          </div>
        </div>
        {budgetError && (
          <div className="sg-row sg-row-error" role="alert">
            <div className="sg-row-l">
              <div className="sg-row-sub" style={{ color: 'var(--danger, #c0392b)' }}>
                {budgetError}
              </div>
            </div>
          </div>
        )}
      </section>

      {/* Data */}
      <section className="sg-card">
        <div className="sg-card-head">資料管理</div>
        <button type="button" className="sg-action-row">
          <div className="sg-row-l">
            <div className="sg-row-title">匯出 CSV</div>
            <div className="sg-row-sub">下載所有交易紀錄</div>
          </div>
          <span className="sg-action-arrow">→</span>
        </button>
        <button type="button" className="sg-action-row">
          <div className="sg-row-l">
            <div className="sg-row-title">匯出 JSON</div>
            <div className="sg-row-sub">完整資料備份，可重新匯入</div>
          </div>
          <span className="sg-action-arrow">→</span>
        </button>
        {/* TODO(PG M4): 「清除所有交易資料」/「重設分類為預設」之前只 mutate
            client state，不會落地到後端，誤導使用者。等 PG M4 補 bulk-delete
            endpoint + categories CRUD 再以伺服器端真實寫入重新露出。 */}
        <button type="button" className="sg-action-row sg-row-last" onClick={onLogout}>
          <div className="sg-row-l">
            <div className="sg-row-title">登出</div>
            <div className="sg-row-sub">結束目前 session，回到登入頁</div>
          </div>
          <span className="sg-action-arrow">→</span>
        </button>
      </section>

      {/* About */}
      <section className="sg-card">
        <div className="sg-card-head">關於</div>
        <div className="sg-row sg-row-last">
          <div className="sg-row-l">
            <div className="sg-row-title">版本</div>
          </div>
          <div className="sg-value">Ledger 0.1 · 設計原型</div>
        </div>
      </section>
    </div>
  );
}

function SettingsToggle({ value, onChange }) {
  return (
    <button
      type="button"
      className={'sg-toggle ' + (value ? 'sg-toggle-on' : '')}
      onClick={() => onChange(!value)}
      role="switch"
      aria-checked={value}
    >
      <span className="sg-toggle-knob" />
    </button>
  );
}


// ──────────────────────────────────────────────────────────────────────────────
// Main App

function App() {
  const [t, setTweak, replaceTweaks] = useTweaks(
    TWEAK_DEFAULTS, { storageKey: 'ledger.tweaks.v1' },
  );

  // Server-side prefs sync. localStorage paints first; on mount we fetch
  // /api/prefs and let server values win (cross-device sync). On change we
  // debounce a PUT so toggle-spam doesn't fan out into N writes. 404 means
  // backend has no prefs table (Notion) — caller stays on localStorage-only.
  //
  // Two races to avoid (codex review):
  //   1. User toggles dark before bootstrap GET resolves → don't let the
  //      remote payload clobber the in-session edit. Track dirtyDuringBoot
  //      and skip replaceTweaks if set, then force-PUT the local state.
  //   2. Debounced PUT A is in flight when PUT B is queued → B could finish
  //      first and A's late arrival overwrites it (whole-blob replace).
  //      Serialize through inflightPutRef + pendingSnapshotRef so only one
  //      request is on the wire at a time and the latest snapshot wins.
  const prefsSyncedRef = React.useRef(false);
  const dirtyDuringBootRef = React.useRef(false);
  const inflightPutRef = React.useRef(false);
  const pendingSnapshotRef = React.useRef(null);
  const latestTRef = React.useRef(t);
  const mountedRef = React.useRef(false);
  useEffect(() => { latestTRef.current = t; }, [t]);

  const flushPrefsWrite = useCallback(async (snapshot) => {
    if (inflightPutRef.current) {
      pendingSnapshotRef.current = snapshot;
      return;
    }
    inflightPutRef.current = true;
    try {
      let current = snapshot;
      while (current) {
        // eslint-disable-next-line no-await-in-loop
        await LEDGER_API.apiPutPrefs(current);
        current = pendingSnapshotRef.current;
        pendingSnapshotRef.current = null;
      }
    } finally {
      inflightPutRef.current = false;
    }
  }, []);

  useEffect(() => {
    let cancelled = false;
    (async () => {
      const remote = await LEDGER_API.apiGetPrefs();
      if (cancelled) return;
      // Don't clobber edits the user made while GET was in flight.
      const userTouched = dirtyDuringBootRef.current;
      if (
        !userTouched && remote
        && typeof remote === 'object'
        && Object.keys(remote).length
      ) {
        replaceTweaks(remote);
      }
      prefsSyncedRef.current = true;
      // If the user edited during bootstrap, push their state up now so the
      // server isn't stale. The debounced PUT effect skipped these edits
      // because prefsSyncedRef was still false when they fired.
      if (userTouched) {
        flushPrefsWrite(latestTRef.current);
      }
    })();
    return () => { cancelled = true; };
  }, [replaceTweaks, flushPrefsWrite]);

  useEffect(() => {
    if (!mountedRef.current) { mountedRef.current = true; return undefined; }
    if (!prefsSyncedRef.current) {
      dirtyDuringBootRef.current = true;
      return undefined;
    }
    const handle = setTimeout(() => { flushPrefsWrite(t); }, 500);
    return () => clearTimeout(handle);
  }, [t, flushPrefsWrite]);

  // Categories state — owns the live list of main categories + their sub-cats.
  // Mirror to global `CATEGORIES` so legacy reads stay current.
  const [cats, setCatsState] = useState(CATEGORIES);
  const setCats = useCallback((updater) => {
    setCatsState((cur) => {
      const next = typeof updater === 'function' ? updater(cur) : updater;
      CATEGORIES = next; // keep global mirror in sync
      return next;
    });
  }, []);

  // Bulk selection on transaction list
  const [bulkMode, setBulkMode] = useState(false);
  // In-flight guard for create/edit/duplicate/delete/bulk-delete — prevents
  // double-submit on fast clicks AND tells the ledger-tab polling effect to
  // skip a tick so an in-flight mutation can't be clobbered by a stale fetch.
  // Passed to DetailPanel to disable the 儲存/複製 buttons.
  const [saving, setSaving] = useState(false);
  // Mirror saving in a ref so the polling effect can read the latest value
  // both before AND after `await apiFetchTransactions(...)` (closure capture
  // would otherwise let a fetch that started before a delete still apply).
  const savingRef = useRef(false);
  useEffect(() => { savingRef.current = saving; }, [saving]);
  const [selectedTxIds, setSelectedTxIds] = useState(() => new Set());

  // Category manager overlay
  const [showCatManager, setShowCatManager] = useState(false);

  // Monthly budget — server-stored on PG via /api/ledger/budget (preferred).
  // On the legacy Notion backend the endpoint 404s and we fall back to
  // localStorage so the value still persists across reloads.
  const BUDGET_KEY_LEGACY = 'ledger.budget.v1';
  const budgetFromServer = !!(window.LEDGER_DATA && window.LEDGER_DATA.BUDGET_FROM_SERVER);
  const [budget, setBudgetRaw] = useState(() => {
    if (budgetFromServer) return MONTH_BUDGET || 0;
    try {
      const cached = localStorage.getItem(BUDGET_KEY_LEGACY);
      if (cached != null) {
        const n = Number(cached);
        if (Number.isFinite(n) && n >= 0) return n;
      }
    } catch (e) { /* private mode / quota */ }
    return MONTH_BUDGET || 0;
  });
  const [budgetError, setBudgetError] = useState('');

  useEffect(() => {
    // One-shot localStorage → server migration. Only runs when the server
    // endpoint is reachable AND the user has a non-zero cached value AND
    // the server is still at the default 0. Drops the legacy key on
    // success; leaves it for a retry on failure.
    if (!budgetFromServer) return;
    if (!window.LEDGER_API || !window.LEDGER_API.apiPatchBudget) return;
    if (MONTH_BUDGET && MONTH_BUDGET > 0) {
      try { localStorage.removeItem(BUDGET_KEY_LEGACY); } catch (e) {}
      return;
    }
    let cached = null;
    try {
      const raw = localStorage.getItem(BUDGET_KEY_LEGACY);
      if (raw != null) {
        const n = Number(raw);
        if (Number.isFinite(n) && n > 0) cached = n;
      }
    } catch (e) { /* private mode */ }
    if (cached == null) return;
    (async () => {
      try {
        const saved = await window.LEDGER_API.apiPatchBudget(cached);
        setBudgetRaw(saved);
        try { localStorage.removeItem(BUDGET_KEY_LEGACY); } catch (e) {}
      } catch (e) { /* swallow — next explicit setBudget will retry */ }
    })();
  }, [budgetFromServer]);

  const setBudget = useCallback((v) => {
    const next = Number(v) || 0;
    const previous = budget;
    setBudgetRaw(next);
    setBudgetError('');
    if (budgetFromServer && window.LEDGER_API && window.LEDGER_API.apiPatchBudget) {
      // Optimistic UI; revert + surface error if the PATCH fails so the
      // user does not believe a save succeeded when it did not.
      (async () => {
        try {
          const saved = await window.LEDGER_API.apiPatchBudget(next);
          setBudgetRaw(saved);
        } catch (err) {
          setBudgetRaw(previous);
          setBudgetError(err && err.message ? err.message : '預算儲存失敗');
        }
      })();
    } else {
      // Notion fallback (and any other no-server path): keep persisting to
      // localStorage so the value survives reloads.
      try { localStorage.setItem(BUDGET_KEY_LEGACY, String(next)); } catch (e) {}
    }
  }, [budget, budgetFromServer]);

  // Generic confirm dialog state
  const [confirm, setConfirm] = useState(null);

  // tweak-driven CSS vars
  useEffect(() => {
    const root = document.documentElement;
    root.style.setProperty('--accent', t.accent);
    root.style.setProperty('--accent-focus', t.accent);
    root.dataset.density = t.density;
    if (t.dark) root.dataset.theme = 'dark';
    else delete root.dataset.theme;
  }, [t.accent, t.density, t.dark]);

  const [month, setMonth] = useState(new Date(TODAY.getFullYear(), TODAY.getMonth(), 1));
  // range = null means "view entire visible month"; otherwise {start, end, preset}
  const [range, setRange] = useState(null);
  const [filterCat, setFilterCat] = useState(null);
  const [search, setSearch] = useState('');
  const [openCat, setOpenCat] = useState('food');
  const [editing, setEditing] = useState(null);
  const [data, setData] = useState(TRANSACTIONS);
  const [view, setView] = useState('list'); // 'list' | 'chart'
  const [tab, setTab] = useState('ledger'); // 'ledger' | 'categories' | 'recurring' | 'projects' | 'settings'
  // Projects sub-state — tab='projects' shows list, or detail when projectViewId set
  const [projectViewId, setProjectViewId] = useState(null);
  const [projectEditState, setProjectEditState] = useState(null); // null | {mode, project?}
  const [projectsRefreshKey, setProjectsRefreshKey] = useState(0);
  const [projectsCatalog, setProjectsCatalog] = useState([]); // for RecordForm picker
  // Recurring (M4) sub-state — tab='recurring' shows list, or detail when recurringView set
  const [recurringRefreshKey, setRecurringRefreshKey] = useState(0);
  const [recurringView, setRecurringView] = useState(null); // null | ruleId
  const [recurringEditState, setRecurringEditState] = useState(null); // null | {mode, rule?}
  const [categoryFocus, setCategoryFocus] = useState(null); // categoryId in focus mode
  const [subFilter, setSubFilter] = useState(null); // sub-category filter inside focus mode
  const [kbdHintVisible, setKbdHintVisible] = useState(true);
  const searchRef = useRef(null);

  // Auto-hide keyboard hint after 6s
  useEffect(() => {
    const t = setTimeout(() => setKbdHintVisible(false), 6000);
    return () => clearTimeout(t);
  }, []);

  // Initial load of projects catalog for ProjectPicker in DetailPanel.
  useEffect(() => {
    if (!window.LEDGER_API || !window.LEDGER_API.apiListProjects) return;
    window.LEDGER_API.apiListProjects(false)
      .then(setProjectsCatalog)
      .catch(() => setProjectsCatalog([]));
  }, []);

  // Background polling on the ledger tab so records written via the TG bot
  // surface without a manual reload. Constraints:
  //   - only while tab === 'ledger' (other tabs don't read `data`)
  //   - skip when document.hidden (battery / data on mobile background tabs)
  //   - skip while a mutation is in flight (savingRef covers create/edit/
  //     duplicate/delete/bulk-delete) so an older fetch can't reinsert a row
  //     a concurrent delete has already pruned. Re-checked after `await`
  //     because the mutation may start during the in-flight fetch.
  //   - one fetch at a time via inFlightRef — guards against slow networks
  //     where >1 interval tick fires before the previous /api/records resolves
  //     (out-of-order responses would otherwise overwrite newer data).
  //   - 3 consecutive failures → pause until the deps change again. 401 stops
  //     polling immediately (silent — next user action hits the existing
  //     login flow via apiPatch/apiCreate/etc. which already surface 401).
  useEffect(() => {
    if (tab !== 'ledger') return undefined;
    if (!window.LEDGER_API || !window.LEDGER_API.apiFetchTransactions) return undefined;
    const nameToSlug = Object.fromEntries(cats.map((c) => [c.id, c.slug]));
    let cancelled = false;
    let failCount = 0;
    const inFlightRef = { current: false };
    const interval = setInterval(async () => {
      if (cancelled || document.hidden || savingRef.current) return;
      if (inFlightRef.current) return;
      inFlightRef.current = true;
      try {
        const fresh = await window.LEDGER_API.apiFetchTransactions(nameToSlug);
        // Re-check after await: mutation may have started while we were
        // fetching, in which case discarding this snapshot is safer than
        // racing setData against the mutation's optimistic update.
        if (cancelled || savingRef.current) return;
        setData(fresh);
        failCount = 0;
      } catch (err) {
        if (err && err.status === 401) {
          clearInterval(interval);
          return;
        }
        failCount += 1;
        if (failCount >= 3) {
          clearInterval(interval);
        }
      } finally {
        inFlightRef.current = false;
      }
    }, 10_000);
    return () => { cancelled = true; clearInterval(interval); };
  }, [tab, cats]);

  // Effective range — when range is null, view = entire visible month
  const effectiveRange = useMemo(() => {
    if (range) return range;
    const start = new Date(month.getFullYear(), month.getMonth(), 1);
    const end   = new Date(month.getFullYear(), month.getMonth() + 1, 0);
    return { start, end, preset: 'monthAuto' };
  }, [range, month]);

  const inEffectiveRange = useCallback((d) => {
    const t = new Date(d).setHours(0,0,0,0);
    const s = new Date(effectiveRange.start).setHours(0,0,0,0);
    const e = new Date(effectiveRange.end).setHours(0,0,0,0);
    return t >= s && t <= e;
  }, [effectiveRange]);

  // Derived: transactions inside the calendar month (for calendar heat + month total)
  const monthTx = useMemo(() => data.filter((t) => F.sameMonth(t.date, month)), [data, month]);

  // Transactions inside the effective range — used by list + rank
  const rangeTx = useMemo(() => data.filter((t) => inEffectiveRange(t.date)), [data, inEffectiveRange]);

  // Focus-mode transactions — ALL of the chosen category, ignoring range filter
  const focusTx = useMemo(() => {
    if (!categoryFocus) return [];
    return data.filter((t) => t.categoryId === categoryFocus);
  }, [data, categoryFocus]);

  // Sub-category totals (inside focus) for the sub-cat chip row
  const focusSubTotals = useMemo(() => {
    if (!categoryFocus) return {};
    const m = {};
    focusTx.forEach((t) => { m[t.sub] = (m[t.sub] || 0) + t.amount; });
    return m;
  }, [focusTx, categoryFocus]);

  const focusTotal = useMemo(() => focusTx.reduce((s, t) => s + t.amount, 0), [focusTx]);

  // Lifetime totals per category — for the "全部分類" view in the rank panel
  const lifetimeTotals = useMemo(() => {
    const m = {};
    data.forEach((t) => { m[t.categoryId] = (m[t.categoryId] || 0) + t.amount; });
    return m;
  }, [data]);

  // Filtered transactions for the list (range + category + search) OR focus mode
  const filtered = useMemo(() => {
    if (categoryFocus) {
      return focusTx.filter((t) => {
        if (subFilter && t.sub !== subFilter) return false;
        if (search) {
          const q = search.toLowerCase();
          if (!(
            (t.name || '').toLowerCase().includes(q)
            || (t.note || '').toLowerCase().includes(q)
            || t.sub.includes(search)
            || String(t.amount).includes(search)
          )) return false;
        }
        return true;
      });
    }
    return rangeTx.filter((t) => {
      if (filterCat && t.categoryId !== filterCat) return false;
      if (search) {
        const q = search.toLowerCase();
        if (!(
          (t.name || '').toLowerCase().includes(q)
          || (t.note || '').toLowerCase().includes(q)
          || t.category.includes(search)
          || t.sub.includes(search)
          || String(t.amount).includes(search)
        )) return false;
      }
      return true;
    });
  }, [categoryFocus, focusTx, subFilter, rangeTx, filterCat, search]);

  const rangeTotal = useMemo(() => rangeTx.reduce((s, t) => s + t.amount, 0), [rangeTx]);
  const monthTotal = useMemo(() => monthTx.reduce((s, t) => s + t.amount, 0), [monthTx]);
  const todayTotal = useMemo(
    () => data.filter((t) => F.sameDay(t.date, TODAY)).reduce((s, t) => s + t.amount, 0),
    [data]
  );
  const yesterdayTotal = useMemo(() => {
    const y = new Date(TODAY); y.setDate(y.getDate() - 1);
    return data.filter((t) => F.sameDay(t.date, y)).reduce((s, t) => s + t.amount, 0);
  }, [data]);
  const lastMonthAvg = useMemo(() => {
    const lm = new Date(TODAY.getFullYear(), TODAY.getMonth() - 1, 1);
    const days = new Date(lm.getFullYear(), lm.getMonth() + 1, 0).getDate();
    const sum = data.filter((t) => F.sameMonth(t.date, lm)).reduce((s, t) => s + t.amount, 0);
    return sum / days;
  }, [data]);
  const monthDaysTotal = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
  const monthDaysElapsed = F.sameMonth(month, TODAY) ? TODAY.getDate() : monthDaysTotal;
  const avgPerDay = monthTotal / monthDaysElapsed;

  // Range-derived stats (only meaningful when range is set)
  const rangeDays = useMemo(() => {
    if (!range) return 0;
    const s = new Date(range.start); s.setHours(0,0,0,0);
    const e = new Date(range.end);   e.setHours(0,0,0,0);
    return Math.round((e - s) / 86400000) + 1;
  }, [range]);

  const rangeAvg = rangeDays > 0 ? rangeTotal / rangeDays : 0;

  const rangeTopDay = useMemo(() => {
    if (!range || rangeTx.length === 0) return null;
    const m = new Map();
    rangeTx.forEach((t) => {
      const k = F.dateKey(t.date);
      const cur = m.get(k) || { date: new Date(t.date), total: 0 };
      cur.total += t.amount;
      m.set(k, cur);
    });
    let top = null;
    m.forEach((v) => { if (!top || v.total > top.total) top = v; });
    return top;
  }, [range, rangeTx]);

  const prevRangeAvg = useMemo(() => {
    if (!range || rangeDays === 0) return 0;
    const s = new Date(range.start); s.setHours(0,0,0,0);
    const prevEnd = new Date(s); prevEnd.setDate(prevEnd.getDate() - 1);
    const prevStart = new Date(prevEnd); prevStart.setDate(prevStart.getDate() - (rangeDays - 1));
    const ps = prevStart.setHours(0,0,0,0);
    const pe = prevEnd.setHours(0,0,0,0);
    const sum = data
      .filter((t) => {
        const td = new Date(t.date).setHours(0,0,0,0);
        return td >= ps && td <= pe;
      })
      .reduce((a, t) => a + t.amount, 0);
    return sum / rangeDays;
  }, [range, rangeDays, data]);

  // By-day totals for calendar
  const byDay = useMemo(() => {
    const m = {};
    monthTx.forEach((t) => {
      const k = F.dateKey(t.date);
      m[k] = (m[k] || 0) + t.amount;
    });
    return m;
  }, [monthTx]);

  // Category and sub-category totals — based on the range view (so rank reflects what you're looking at)
  const totals = useMemo(() => {
    const m = {};
    rangeTx.forEach((t) => { m[t.categoryId] = (m[t.categoryId] || 0) + t.amount; });
    return m;
  }, [rangeTx]);
  const subTotals = useMemo(() => {
    const m = {};
    rangeTx.forEach((t) => {
      if (!m[t.categoryId]) m[t.categoryId] = {};
      m[t.categoryId][t.sub] = (m[t.categoryId][t.sub] || 0) + t.amount;
    });
    return m;
  }, [rangeTx]);

  // Handlers (declared up-front so SubNav can reference)
  const handlePrev = () => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1));
  const handleNext = () => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1));
  const handleToday = () => { setMonth(new Date(TODAY.getFullYear(), TODAY.getMonth(), 1)); setRange(null); };

  const handleRangePreset = (preset) => {
    if (preset === 'month') {
      setMonth(new Date(TODAY.getFullYear(), TODAY.getMonth(), 1));
      setRange(null);
      return;
    }
    if (preset === 'last7' || preset === 'last30' || preset === 'last90') {
      const days = preset === 'last7' ? 6 : preset === 'last30' ? 29 : 89;
      const end = new Date(TODAY);
      const start = new Date(TODAY);
      start.setDate(TODAY.getDate() - days);
      setMonth(new Date(start.getFullYear(), start.getMonth(), 1));
      setRange({ start, end, preset });
      return;
    }
    if (preset === 'lastMonth') {
      const lm = new Date(TODAY.getFullYear(), TODAY.getMonth() - 1, 1);
      const lmEnd = new Date(TODAY.getFullYear(), TODAY.getMonth(), 0);
      setMonth(lm);
      setRange({ start: lm, end: lmEnd, preset });
      return;
    }
    if (preset === 'ytd') {
      const start = new Date(TODAY.getFullYear(), 0, 1);
      const end = new Date(TODAY);
      setMonth(new Date(start.getFullYear(), start.getMonth(), 1));
      setRange({ start, end, preset });
      return;
    }
  };

  const handleCalendarPick = (d) => {
    // Cross-month / cross-year selection:
    //  - no range, or range is a finalised 2-day range → start a new pending selection
    //  - single-day range (pending), clicked same day → clear
    //  - single-day range (pending), clicked different day → finalise to a range
    // The month stepper does NOT clear range, so the user can navigate to another month
    // between the two clicks to make a cross-month selection.
    if (!range) {
      setRange({ start: d, end: d, preset: 'custom', pending: true });
      return;
    }
    // If a finalised range exists, restart selection from this date
    if (!range.pending) {
      setRange({ start: d, end: d, preset: 'custom', pending: true });
      return;
    }
    if (F.sameDay(range.start, d)) {
      setRange(null);
      return;
    }
    const a = range.start, b = d;
    const start = a < b ? a : b;
    const end   = a < b ? b : a;
    setRange({ start, end, preset: 'custom', pending: false });
  };

  // ── API <-> tx shape adapters ──────────────────────────────────────────────
  // Backend stores `item`; v3 UI reads `name`. UI's `account` maps to
  // backend `payment_method` (added in M4). slugByName built from live cats so
  // a future ManageCategoriesModal stays consistent.
  const slugByName = useMemo(
    () => Object.fromEntries(cats.map((c) => [c.id, c.slug])),
    [cats]
  );
  const apiToTx = useCallback((r) => ({
    id: r.id,
    date: window.LEDGER_API.parseDate(r.date),
    categoryId: r.category,
    categorySlug: slugByName[r.category] || 'other',
    category: r.category,
    sub: r.sub_category,
    amount: r.amount,
    note: r.note || '',
    name: r.item || '',
    account: r.payment_method || '',
    time: r.time || '',
    amountOriginal: r.amount_original != null ? r.amount_original : null,
    currencyOriginal: r.currency_original || '',
    fxRate: r.fx_rate != null ? r.fx_rate : null,
    projectIds: Array.isArray(r.project_ids) ? r.project_ids.slice() : [],
  }), [slugByName]);
  const txToPayload = (tx) => ({
    date: F.dateKey(tx.date),
    item: tx.name || '',
    amount: Number(tx.amount) || 0,
    category: tx.category || tx.categoryId,
    sub_category: tx.sub,
    note: tx.note || '',
    payment_method: tx.account || '',
    time: tx.time || null,
    amount_original: tx.amountOriginal != null && tx.amountOriginal !== ''
      ? Number(tx.amountOriginal) : null,
    currency_original: tx.currencyOriginal || null,
    fx_rate: tx.fxRate != null && tx.fxRate !== '' ? Number(tx.fxRate) : null,
    project_ids: Array.isArray(tx.projectIds) ? tx.projectIds.slice() : [],
  });

  const handleAdd = () => {
    const defaultCat = cats[0];
    setEditing({
      id: 'new-' + Date.now(),
      date: F.taipeiToday(),
      categoryId: defaultCat.id,
      categorySlug: defaultCat.slug,
      category: defaultCat.name,
      sub: (defaultCat.sub && defaultCat.sub[0]) || '',
      amount: 0,
      name: '',
      note: '',
      account: '',
      _isNew: true,
    });
  };

  const handleSave = async (draft) => {
    // Guard: a fast double-click would otherwise fire two apiCreate calls
    // and silently duplicate the record. handleDuplicate gets the same guard.
    if (saving) return;
    setSaving(true);
    try {
      if (draft._isNew) {
        const r = await window.LEDGER_API.apiCreate(txToPayload(draft));
        setData((cur) => [apiToTx(r), ...cur]);
      } else {
        const r = await window.LEDGER_API.apiPatch(draft.id, txToPayload(draft));
        setData((cur) => {
          const idx = cur.findIndex((t) => t.id === draft.id);
          if (idx === -1) return cur;
          const next = cur.slice();
          next[idx] = apiToTx(r);
          return next;
        });
      }
      setEditing(null);
    } catch (err) {
      alert((draft._isNew ? '新增' : '儲存') + '失敗：' + err.message);
    } finally {
      setSaving(false);
    }
  };
  const handleDelete = (id) => {
    const tx = data.find((t) => t.id === id);
    if (!tx) return;
    // Never-saved draft: just close the panel; nothing on the server to remove.
    if (typeof id === 'string' && id.startsWith('new-')) {
      setEditing(null);
      return;
    }
    setConfirm({
      title: '刪除此筆交易？',
      body: `${tx.name || '未命名'} · NT$ ${tx.amount.toLocaleString('en-US')}　此操作無法復原。`,
      danger: true,
      confirmLabel: '刪除',
      onConfirm: async () => {
        // setSaving covers more than create/edit — see polling effect; it
        // also gates the ledger-tab poll so a stale /api/records snapshot
        // can't reinsert the row we're about to drop client-side.
        setSaving(true);
        try {
          await window.LEDGER_API.apiDelete(id);
          setData((cur) => cur.filter((t) => t.id !== id));
          setEditing(null);
        } catch (err) {
          alert('刪除失敗：' + err.message);
        } finally {
          setSaving(false);
        }
      },
    });
  };
  const handleDuplicate = async (draft) => {
    if (saving) return;
    setSaving(true);
    try {
      const r = await window.LEDGER_API.apiCreate(
        txToPayload({ ...draft, date: F.taipeiToday() })
      );
      const copy = apiToTx(r);
      setData((cur) => [copy, ...cur]);
      setEditing(copy);
    } catch (err) {
      alert('複製失敗：' + err.message);
    } finally {
      setSaving(false);
    }
  };

  // Keyboard shortcuts
  useEffect(() => {
    const onKey = (e) => {
      if (editing) {
        if (e.key === 'Escape') setEditing(null);
        return;
      }
      const tag = (e.target.tagName || '').toLowerCase();
      const isInput = tag === 'input' || tag === 'textarea';
      if (e.key === '/' && !isInput) { e.preventDefault(); searchRef.current?.focus(); }
      else if (e.key.toLowerCase() === 'n' && !isInput) { e.preventDefault(); handleAdd(); }
      else if (e.key === 'Escape') { setRange(null); setFilterCat(null); setSearch(''); }
      else if (e.key === 'ArrowLeft' && !isInput) handlePrev();
      else if (e.key === 'ArrowRight' && !isInput) handleNext();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [editing, month]);

  return (
    <CategoriesContext.Provider value={{ cats, setCats }}>
    <div className="ledger" data-density={t.density}>
      <GlobalNav
        onAdd={handleAdd}
        onSearch={setSearch}
        search={search}
        density={t.density}
        accent={t.accent}
        tab={tab}
        onTab={(next) => {
          setTab(next);
          if (next === 'ledger') { setCategoryFocus(null); setSubFilter(null); }
          if (next === 'recurring') { setRecurringView(null); }
        }}
      />
      <SubNav
        month={month}
        onPrev={handlePrev}
        onNext={handleNext}
        onToday={handleToday}
        onPickMonth={(d) => { setMonth(d); }}
        rangePreset={range?.preset ?? 'month'}
        onRangePreset={handleRangePreset}
        hasCustomRange={range?.preset === 'custom'}
      />

      <main className="page">
        <StatRow
          monthTotal={monthTotal}
          todayTotal={todayTotal}
          avgPerDay={avgPerDay}
          budget={budget}
          monthDaysElapsed={monthDaysElapsed}
          monthDaysTotal={monthDaysTotal}
          yesterdayTotal={yesterdayTotal}
          lastMonthAvg={lastMonthAvg}
          range={range}
          rangeTotal={rangeTotal}
          rangeDays={rangeDays}
          rangeAvg={rangeAvg}
          rangeTopDay={rangeTopDay}
          prevRangeAvg={prevRangeAvg}
        />

        {tab === 'ledger' && (
          <div className="grid grid-ledger">
            <section className="card card-cal">
              <Calendar
                month={month}
                range={effectiveRange.preset === 'monthAuto' ? null : effectiveRange}
                onPickDate={handleCalendarPick}
                onClearRange={() => setRange(null)}
                byDay={byDay}
                heatmap={t.heatmap}
                today={TODAY}
                pickingHint={range?.pending ? '再點一天完成範圍（可跨月）' : null}
              />
              {range && !range.pending && (
                <div className="cal-selbar">
                  <span>
                    <b>{F.longDate(range.start)}</b>
                    {range.start.getTime() !== range.end.getTime() && (
                      <> － <b>{F.longDate(range.end)}</b></>
                    )}
                    <span className="cal-selbar-tail">　共 {rangeTx.length} 筆 · NT$ {rangeTotal.toLocaleString('en-US')}</span>
                  </span>
                </div>
              )}
              {range && range.pending && (
                <div className="cal-selbar cal-selbar-pending">
                  <span>起點 <b>{F.longDate(range.start)}</b> — 等待選擇終點…</span>
                </div>
              )}
            </section>

            <section className="card card-rank">
              <CategoryRank
                totals={totals}
                subTotals={subTotals}
                expanded={openCat}
                onExpand={setOpenCat}
                monthTotal={rangeTotal}
                rangeLabel={range ? '範圍合計' : '本月合計'}
                onFilter={setFilterCat}
                filterCat={filterCat}
                onSeeAll={() => setTab('categories')}
              />
            </section>

            <section className="card card-list">
              <div className="list-head">
                <div className="list-head-l">
                  <div className="cal-eyebrow">{view === 'list' ? '交易明細' : '圖表'}</div>
                  <div className="list-head-meta">
                    {view === 'list' ? `顯示 ${filtered.length} 筆` : `區間 ${rangeTx.length} 筆`}
                    {range && (
                      <span className="tag">
                        {F.longDate(range.start)}
                        {range.start.getTime() !== range.end.getTime() && ' － ' + F.longDate(range.end)}
                      </span>
                    )}
                    {filterCat && view === 'list' && <span className="tag">{CATEGORIES.find(c => c.id === filterCat).name}</span>}
                    {search && view === 'list' && <span className="tag">"{search}"</span>}
                    {(range || filterCat || search) && (
                      <button className="link" onClick={() => { setRange(null); setFilterCat(null); setSearch(''); }} type="button">清除全部</button>
                    )}
                  </div>
                </div>
                <div className="list-head-r">
                  {view === 'list' && (
                    <button
                      type="button"
                      className={'view-tab view-tab-solo ' + (bulkMode ? 'view-tab-on' : '')}
                      onClick={() => {
                        setBulkMode((v) => {
                          const next = !v;
                          if (!next) setSelectedTxIds(new Set());
                          return next;
                        });
                      }}
                      title={bulkMode ? '結束選取' : '進入選取模式'}
                    >
                      <svg width="13" height="13" viewBox="0 0 16 16">
                        <rect x="2" y="2" width="12" height="12" rx="2.5" stroke="currentColor" strokeWidth="1.3" fill="none"/>
                        {bulkMode && <path d="M5 8l2 2 4-4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round"/>}
                      </svg>
                      {bulkMode ? '結束選取' : '選取'}
                    </button>
                  )}
                  <div className="view-toggle" role="tablist" aria-label="檢視模式">
                    <button
                      type="button"
                      className={'view-tab ' + (view === 'list' ? 'view-tab-on' : '')}
                      onClick={() => setView('list')}
                    >
                      <svg width="13" height="13" viewBox="0 0 16 16"><path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" fill="none"/></svg>
                      明細
                    </button>
                    <button
                      type="button"
                      className={'view-tab ' + (view === 'chart' ? 'view-tab-on' : '')}
                      onClick={() => setView('chart')}
                    >
                      <svg width="13" height="13" viewBox="0 0 16 16"><path d="M3 13V8M7 13V4M11 13v-6M14 13H2" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" fill="none"/></svg>
                      圖表
                    </button>
                  </div>
                </div>
              </div>
              {view === 'list' && bulkMode && (
                <BulkActionBar
                  count={selectedTxIds.size}
                  visibleTx={filtered}
                  selectedIds={selectedTxIds}
                  amount={filtered.filter((t) => selectedTxIds.has(t.id)).reduce((s, t) => s + t.amount, 0)}
                  onSelectAll={() => setSelectedTxIds(new Set(filtered.map((t) => t.id)))}
                  onClear={() => setSelectedTxIds(new Set())}
                  onDelete={() => {
                    const ids = Array.from(selectedTxIds);
                    setConfirm({
                      title: `刪除 ${ids.length} 筆交易？`,
                      body: '此操作無法復原。',
                      danger: true,
                      confirmLabel: '全部刪除',
                      onConfirm: async () => {
                        // setSaving gates the ledger-tab poll (see polling
                        // effect) so a stale /api/records snapshot can't
                        // reinsert rows we're about to drop client-side.
                        setSaving(true);
                        try {
                          const { succeeded, failed } =
                            await window.LEDGER_API.apiBulkDelete(ids);
                          if (succeeded.length) {
                            const drop = new Set(succeeded);
                            setData((cur) => cur.filter((t) => !drop.has(t.id)));
                            setSelectedTxIds((cur) => {
                              const next = new Set(cur);
                              succeeded.forEach((id) => next.delete(id));
                              return next;
                            });
                          }
                          if (failed.length === 0) setBulkMode(false);
                          else alert(`部分刪除失敗：${failed.length}/${ids.length} 筆未完成，請重試。`);
                        } catch (err) {
                          alert('刪除失敗：' + err.message);
                        } finally {
                          setSaving(false);
                        }
                      },
                    });
                  }}
                />
              )}
              {view === 'list' && (
                <div className="list-filter-row">
                  <button
                    className={'chip ' + (filterCat == null ? 'chip-on' : '')}
                    onClick={() => setFilterCat(null)}
                    type="button"
                  >全部分類</button>
                  {cats.map((c) => {
                    const ct = totals[c.id] || 0;
                    if (ct === 0) return null;
                    return (
                      <button
                        key={c.id}
                        className={'chip chip-cat tx-cat-' + c.slug + ' ' + (filterCat === c.id ? 'chip-on' : '')}
                        onClick={() => setFilterCat(filterCat === c.id ? null : c.id)}
                        type="button"
                      >
                        <span className="cat-dot" />
                        {c.name}
                        <span className="chip-count">{ct >= 10000 ? (ct/1000).toFixed(1) + 'k' : ct.toLocaleString('en-US')}</span>
                      </button>
                    );
                  })}
                  <button
                    type="button"
                    className="chip chip-link"
                    onClick={() => setTab('categories')}
                  >完整分類分析 →</button>
                </div>
              )}
              {view === 'list' ? (
                <TxList
                  tx={filtered}
                  onPick={setEditing}
                  selectedId={editing?.id}
                  density={t.density}
                  hideCents={t.hideCents}
                  bulkMode={bulkMode}
                  selectedIds={selectedTxIds}
                  onToggleSelect={(id) => setSelectedTxIds((cur) => {
                    const next = new Set(cur);
                    next.has(id) ? next.delete(id) : next.add(id);
                    return next;
                  })}
                />
              ) : (
                <Charts
                  rangeTx={rangeTx}
                  range={effectiveRange}
                  totals={totals}
                />
              )}
            </section>
          </div>
        )}

        {tab === 'projects' && !projectViewId && (
          <window.Projects.ProjectsPage
            refreshKey={projectsRefreshKey}
            onCreateProject={() => setProjectEditState({ mode: 'create' })}
            onOpenProject={(id) => setProjectViewId(id)}
            onEditProject={(p) => setProjectEditState({ mode: 'edit', project: p })}
          />
        )}

        {tab === 'projects' && projectViewId && (
          <window.Projects.ProjectDetailView
            projectId={projectViewId}
            refreshKey={projectsRefreshKey}
            onBack={() => setProjectViewId(null)}
          />
        )}

        {tab === 'recurring' && !recurringView && (
          <window.Recurring.RecurringPage
            refreshKey={recurringRefreshKey}
            onCreateRule={() => setRecurringEditState({ mode: 'create' })}
            onOpenRule={(id) => setRecurringView(id)}
            onEditRule={(r) => setRecurringEditState({ mode: 'edit', rule: r })}
          />
        )}
        {tab === 'recurring' && recurringView && (
          <window.Recurring.RuleDetailView
            ruleId={recurringView}
            refreshKey={recurringRefreshKey}
            onBack={() => setRecurringView(null)}
            onEdit={(r) => setRecurringEditState({ mode: 'edit', rule: r })}
            onDelete={() => { setRecurringView(null); setRecurringRefreshKey((n) => n + 1); }}
          />
        )}

        {tab === 'categories' && (
          <CategoriesPage
            data={data}
            range={range}
            rangeTotal={rangeTotal}
            totals={totals}
            lifetimeTotals={lifetimeTotals}
            categoryFocus={categoryFocus}
            setCategoryFocus={setCategoryFocus}
            subFilter={subFilter}
            setSubFilter={setSubFilter}
            search={search}
            setSearch={setSearch}
            density={t.density}
            hideCents={t.hideCents}
            onPick={setEditing}
            editing={editing}
            onManage={() => setShowCatManager(true)}
          />
        )}

        {tab === 'settings' && (
          <SettingsPage
            tweaks={t}
            setTweak={setTweak}
            budget={budget}
            setBudget={setBudget}
            budgetError={budgetError}
            onLogout={() => setConfirm({
              title: '登出？',
              body: '會結束目前 session，需要重新輸入密碼。',
              danger: false,
              confirmLabel: '登出',
              onConfirm: async () => {
                try { await window.LEDGER_API.apiLogout(); } catch (e) {}
                location.reload();
              },
            })}
          />
        )}
      </main>

      {/* Keyboard hint — fades out after first interaction */}
      <div className={'kbd-hint ' + (kbdHintVisible ? '' : 'kbd-hint-hidden')}>
        <kbd>/</kbd> 搜尋
        <kbd>N</kbd> 新增
        <kbd>← →</kbd> 月份
        <kbd>Esc</kbd> 清除
      </div>

      <DetailPanel
        tx={editing}
        data={data}
        saving={saving}
        onClose={() => setEditing(null)}
        onSave={handleSave}
        onDelete={handleDelete}
        onDuplicate={handleDuplicate}
        projectsCatalog={projectsCatalog}
      />

      <TweaksPanel title="Tweaks">
        <TweakSection label="主題" />
        <TweakToggle
          label="深色模式"
          value={t.dark}
          onChange={(v) => setTweak('dark', v)}
        />
        <TweakColor
          label="強調色"
          value={t.accent}
          options={t.dark
            ? ['#2997ff', '#30d158', '#ff9f0a', '#bf5af2', '#ff453a']
            : ['#0066cc', '#1f8a5b', '#c2410c', '#7c3aed', '#111111']}
          onChange={(v) => setTweak('accent', v)}
        />
        <TweakRadio
          label="密度"
          value={t.density}
          options={['寬鬆', '一般', '緊湊']}
          onChange={(v) => setTweak('density', v)}
        />
        <TweakSection label="顯示" />
        <TweakToggle
          label="日曆熱度色塊"
          value={t.heatmap}
          onChange={(v) => setTweak('heatmap', v)}
        />
        <TweakToggle
          label="隱藏小數點"
          value={t.hideCents}
          onChange={(v) => setTweak('hideCents', v)}
        />
      </TweaksPanel>

      {/* Manage categories overlay */}
      {showCatManager && (
        <ManageCategoriesModal
          data={data}
          onClose={() => setShowCatManager(false)}
          onConfirm={(opts) => setConfirm(opts)}
          onChanged={() => { window.location.reload(); }}
        />
      )}

      {/* Project edit/create side panel */}
      {projectEditState && (
        <window.Projects.ProjectEditPanel
          mode={projectEditState.mode}
          project={projectEditState.project}
          onClose={() => setProjectEditState(null)}
          onSaved={() => {
            setProjectEditState(null);
            setProjectsRefreshKey((n) => n + 1);
            // Refresh catalog used by RecordForm picker
            window.LEDGER_API.apiListProjects(false)
              .then(setProjectsCatalog)
              .catch(() => {});
          }}
        />
      )}

      {/* Recurring (M4) edit/create side panel */}
      {recurringEditState && (
        <window.Recurring.RuleEditPanel
          mode={recurringEditState.mode}
          rule={recurringEditState.rule}
          categories={data.categories || []}
          onClose={() => setRecurringEditState(null)}
          onSaved={() => { setRecurringEditState(null); setRecurringRefreshKey((n) => n + 1); }}
          onDelete={() => {
            setRecurringEditState(null);
            setRecurringView(null);
            setRecurringRefreshKey((n) => n + 1);
          }}
        />
      )}

      {/* Generic confirm dialog */}
      {confirm && (
        <ConfirmDialog
          {...confirm}
          onClose={() => setConfirm(null)}
        />
      )}
    </div>
    </CategoriesContext.Provider>
  );
}

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "accent": "#0066cc",
  "density": "一般",
  "heatmap": true,
  "hideCents": true,
  "dark": false
}/*EDITMODE-END*/;

// ──────────────────────────────────────────────────────────────────────────────
// Login gate — shown by __renderLogin() when the session check fails.

function LoginView() {
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [busy, setBusy] = useState(false);

  const submit = async (e) => {
    e.preventDefault();
    if (busy) return;
    setBusy(true);
    setError('');
    try {
      await window.LEDGER_API.apiLogin(password);
      location.reload();
    } catch (err) {
      setError(err.message || '登入失敗');
      setBusy(false);
    }
  };

  return (
    <div className="login-view">
      <form className="login-card" onSubmit={submit}>
        <div className="login-logo">
          <svg width="20" height="20" viewBox="0 0 14 14" aria-hidden="true">
            <rect x="1" y="1" width="12" height="12" rx="3" fill="currentColor" opacity=".9"/>
            <path d="M4 7.5l2 2L10 5" stroke="#0a0a0c" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
          </svg>
          <span>Ledger</span>
        </div>
        <label className="login-label" htmlFor="login-pw">密碼</label>
        <input
          id="login-pw"
          className="login-input"
          type="password"
          value={password}
          autoFocus
          onChange={(e) => setPassword(e.target.value)}
          placeholder="輸入密碼"
        />
        {error && <div className="login-error">{error}</div>}
        <button className="btn-primary login-submit" type="submit" disabled={busy}>
          {busy ? '登入中…' : '登入'}
        </button>
      </form>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────────
// Bootstrap entry points — called by api.js once it knows the session state.
// api.js assigns window.LEDGER_DATA before invoking __startApp().

window.__startApp = function () {
  ({ CATEGORIES: INITIAL_CATEGORIES, TRANSACTIONS, TODAY, MONTH_BUDGET, slugByName: SLUG_BY_NAME } = window.LEDGER_DATA);
  SLUG_BY_NAME = SLUG_BY_NAME || {};
  // Safety net: each category needs a slug for CSS palette, each tx needs a
  // matching categorySlug. api.js fills these from the backend; this fallback
  // protects against schema drift / new Notion categories not yet mapped.
  INITIAL_CATEGORIES = INITIAL_CATEGORIES.map((c) => ({
    ...c,
    slug: c.slug || 'other',
    sub: (c.sub || []).slice(),
  }));
  TRANSACTIONS = TRANSACTIONS.map((t) => ({
    ...t,
    categorySlug: t.categorySlug || SLUG_BY_NAME[t.categoryId] || 'other',
  }));
  CATEGORIES = INITIAL_CATEGORIES.map((c) => ({ ...c, sub: c.sub.slice() }));
  ReactDOM.createRoot(document.getElementById('root')).render(<App />);
};

window.__renderLogin = function () {
  ReactDOM.createRoot(document.getElementById('root')).render(<LoginView />);
};
