// 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 (
Ledger
onSearch(e.target.value)} /> /
); } 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 (
{pickerOpen && ( { onPickMonth(d); setPickerOpen(false); }} onToday={() => { onToday(); setPickerOpen(false); }} onClose={() => setPickerOpen(false)} /> )}
{PRESETS.map((p) => ( ))} {hasCustomRange && ( 自訂 )}
); } // ────────────────────────────────────────────────────────────────────────────── // 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 (
本月花費
NT$ {Math.round(monthTotal).toLocaleString('en-US')}
預算 NT$ {budget.toLocaleString('en-US')} 100 ? 'pace pace-over' : 'pace'}> 預計月底 {Math.round(onPace).toLocaleString('en-US')} · {onPacePct}%
本日花費
NT$ {Math.round(todayTotal).toLocaleString('en-US')}
{`${F.weekday(TODAY)} · ${TODAY.getMonth() + 1}/${TODAY.getDate()}`} {yesterdayTotal > 0 && ( 0 ? 'delta-up' : 'delta-down')}> {dayDelta > 0 ? '↑' : '↓'} {Math.abs(dayDelta)}% vs 昨日 )}
日均
NT$ {Math.round(avgPerDay).toLocaleString('en-US')}
{`過去 ${monthDaysElapsed} 天平均`} {lastMonthAvg > 0 && ( 0 ? 'delta-up' : 'delta-down')}> {avgDelta > 0 ? '↑' : '↓'} {Math.abs(avgDelta)}% vs 上月 )}
); } // ── 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 (
區間總花費
NT$ {Math.round(rangeTotal).toLocaleString('en-US')}
{rangeLabel} · 共 {rangeDays} 天
最高單日
{rangeTopDay ? ( <>
NT$ {Math.round(rangeTopDay.total).toLocaleString('en-US')}
{`${fmtMD(rangeTopDay.date)} · ${F.weekday(rangeTopDay.date)}`} {rangeAvg > 0 && topPctVsAvg > 0 && ( ↑ {topPctVsAvg}% vs 日均 )}
) : ( <>
NT$ 0
區間內無交易
)}
區間日均
NT$ {Math.round(rangeAvg).toLocaleString('en-US')}
{`平均每日 · 共 ${rangeDays} 天`} {prevRangeAvg > 0 && ( 0 ? 'delta-up' : 'delta-down')}> {avgDelta > 0 ? '↑' : '↓'} {Math.abs(avgDelta)}% vs 前 {rangeDays} 天 )}
); } // ────────────────────────────────────────────────────────────────────────────── // 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 (
{yearList.map((y) => ( ))}
{months.map((m, i) => { const isSel = i === month.getMonth() && year === month.getFullYear(); return ( ); })}
Esc 關閉
); } // ────────────────────────────────────────────────────────────────────────────── // 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 (
日曆
{pickingHint && {pickingHint}}
{!range && (
)} {range && ( )}
{['日','一','二','三','四','五','六'].map((w, i) => (
{w}
))}
{cells.map((c) => { if (c.blank) return
; 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 ( ); })}
); } // ────────────────────────────────────────────────────────────────────────────── // 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 (
分類排行
{onSeeAll && ( )}
{rangeLabel || '本月合計'} NT$ {Math.round(monthTotal).toLocaleString('en-US')}
    {ranked.length === 0 && (
  • 本範圍尚無交易紀錄 {onSeeAll && <> · }
  • )} {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 (
  • {isOpen && (
      {subItems.map(([name, total]) => { const sPct = (total / subItems[0][1]) * 100; return (
    • {name} NT$ {total.toLocaleString('en-US')}
    • ); })}
    )}
  • ); })}
); } // ────────────────────────────────────────────────────────────────────────────── // 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 (
已選 {count} 筆 {count > 0 && <> · NT$ {amount.toLocaleString('en-US')}}
{count > 0 && ( )}
); } // ────────────────────────────────────────────────────────────────────────────── // 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 (
這個範圍內沒有紀錄
調整篩選或新增第一筆交易
); } return (
{groups.map((g) => { const dayTotal = g.items.reduce((s, t) => s + t.amount, 0); return (
{F.relativeDay(g.date, TODAY)} · {F.longDate(g.date)}
{g.items.length} 筆 NT$ {dayTotal.toLocaleString('en-US')}
    {g.items.map((t) => { const isSelected = bulkMode && selectedIds?.has(t.id); return (
  • bulkMode ? onToggleSelect(t.id) : onPick(t)} > {bulkMode && ( { e.stopPropagation(); onToggleSelect(t.id); }} > )} {t.category}/{t.sub} {t.name} {t.note && {t.note}} {t.account && {t.account}} {t.time && {t.time}} {t.currencyOriginal && t.amountOriginal != null && ( {Number(t.amountOriginal).toLocaleString('en-US')} {t.currencyOriginal} )} NT$ {t.amount.toLocaleString('en-US')}{hideCents ? '' : '.00'}
  • ); })}
); })}
); } // ────────────────────────────────────────────────────────────────────────────── // 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 (
每日花費
每日合計 區間平均
{dayBuckets.map((d, i) => { const isToday = F.sameDay(d.date, TODAY); const h = (d.total / maxDay) * 100; return (
); })}
{dayBuckets.map((d, i) => { const show = i % labelEvery === 0 || i === dayBuckets.length - 1; return (
{show && {(d.date.getMonth() + 1) + '/' + d.date.getDate()}}
); })}
區間總計NT$ {grandTotal.toLocaleString('en-US')}
日均NT$ {Math.round(avgDay).toLocaleString('en-US')}
單日最高NT$ {Math.round(maxDay).toLocaleString('en-US')}
交易筆數{rangeTx.length}
分類佔比
{slices.length === 0 && ( )} {slices.map((s) => ( ))} {grandTotal >= 100000 ? (grandTotal / 1000).toFixed(0) + 'k' : grandTotal.toLocaleString('en-US')} NT$ · 區間合計
    {ranked.map((c) => (
  • {c.name} {Math.round((c.total / grandTotal) * 100)}%
  • ))}
分類橫條
    {ranked.map((c) => (
  • {c.name} NT$ {c.total.toLocaleString('en-US')}
  • ))}
圖表為 demo,後續可加:月間對比、預算燃盡圖、消費時段熱圖、Top 商家排行。
); } // ────────────────────────────────────────────────────────────────────────────── // 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 (
分類分析

所有分類

目前範圍:{rangeLabel} {hasRange && <> · 合計 NT$ {Math.round(rangeTotal).toLocaleString('en-US')}} {!hasRange && <> · 點上方範圍 chip 可篩選日期}

    {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 (
  • ); })}
); } return ( { 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
; 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 ( {pts.length > 0 && ( )} ); } 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 (

{cat.name}

{rangeLabel} · {allTx.length} 筆交易 {hasRange && allTimeTotal > 0 && allTimeTotal !== total && ( <> · 全部時間 NT$ {allTimeTotal.toLocaleString('en-US')} )}
NT$ {total.toLocaleString('en-US')}
{hasRange ? '範圍月均' : '月均花費'} NT$ {Math.round(monthlyAvg).toLocaleString('en-US')} {monthsWithData} 個月有記錄
最常子分類 {topSub} {subItems[0] ? 'NT$ ' + subItems[0][1].toLocaleString('en-US') : '—'}
子分類覆蓋 {subItems.length} / {cat.sub.length} 已使用 / 可用
最早記錄 {earliest ? (earliest.getFullYear() + '/' + (earliest.getMonth() + 1)) : '—'} 至今
月度走勢
近 24 個月 · {hasRange ? '範圍內加深' : '當前月加粗'}
{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 (
); })}
{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 (
{show && {lbl}}
); })}
子分類佔比
{rangeLabel}
{subItems.length === 0 ? (
範圍內無此分類的記錄
) : (
    {subItems.map(([s, amt]) => (
  • NT$ {amt.toLocaleString('en-US')}
  • ))}
)}
交易明細
顯示 {filtered.length} / {allTx.length} 筆 · {rangeLabel} {subFilter && {subFilter}} {search && "{search}"} {(subFilter || search) && ( )}
{subItems.map(([s, amt]) => ( ))}
); } 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 (
); } return ( <>
setDraft({ ...draft, amountOriginal: e.target.value === '' ? null : Number(e.target.value), })} placeholder="例:1234" />
setDraft({ ...draft, currencyOriginal: e.target.value.toUpperCase(), })} placeholder="USD / JPY / EUR…" list="fx-currency-suggestions" maxLength={3} />
setDraft({ ...draft, fxRate: e.target.value === '' ? null : Number(e.target.value), })} placeholder="例:0.231" />
); } // ────────────────────────────────────────────────────────────────────────────── // 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) { // 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 (
e.stopPropagation()}>
{att.original_filename || '(未命名)'} {_formatAttachSize(att.size_bytes)} · {att.mime_type}
{counter && {counter}}
{editable && ( )}
{items.length > 1 && ( )}
{loadError &&
{loadError}
} {!loadError && isHeic && (
HEIC 無法在瀏覽器預覽 iPhone 原生格式 — 請按上方下載鈕後本機開啟
)} {!loadError && isPdf && (
PDF {att.original_filename} {_formatAttachSize(att.size_bytes)}
)} {!loadError && !isHeic && !isPdf && previewUrl && ( {att.original_filename} )} {!loadError && !isHeic && !isPdf && !previewUrl && ( )}
{items.length > 1 && ( )}
{items.length > 1 && (
{items.map((it, i) => ( ))}
)}
); } // 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 HEIC; if (!url) return ; return ; } 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 (
附件
先儲存這筆紀錄後即可上傳附件
); } 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 (
附件
{items.length > 0 && (
{items.length} 個
)}
{loading &&
載入中…
} {!loading && hasContent && (
    {items.map((att) => (
  • {att.original_filename} {_formatAttachSize(att.size_bytes)}
    {editable && ( )}
  • ))} {uploads.map((u) => (
  • {u.name}
    {u.error ?
    {u.error}
    :
    }
  • ))}
)} {!loading && !hasContent && editable && (
尚未上傳任何附件
)} {editable && (
單檔 ≤ 10MB · 圖片或 PDF
)} {error &&
{error}
} setLightboxIdx(null)} onDelete={_deleteFromLightbox} />
); }); // 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 ). // // 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 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 HEIC
點擊開啟
; } if (err) return ; if (!url) return ; // 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 ( {att.original_filename} 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 ( <>
); } // ─── 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 ( <>
); } // ────────────────────────────────────────────────────────────────────────────── // 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 ( <>

{title}

{body &&

{body}

}
); } // ────────────────────────────────────────────────────────────────────────────── // 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: , 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} 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 ( <>
); } // ────────────────────────────────────────────────────────────────────────────── // 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 (
偏好設定

設定

調整外觀、預算與資料管理

{/* Appearance */}
外觀
深色模式
以 Apple 風格的純黑底配色顯示
setTweak('dark', v)} />
強調色
套用到按鈕、連結與圖表色彩
{accents.map((c) => (
介面密度
影響交易列表的列高
{['寬鬆', '一般', '緊湊'].map((d) => ( ))}
日曆熱度色塊
用顏色深淺呈現每日花費
setTweak('heatmap', v)} />
隱藏小數點
金額不顯示 .00
setTweak('hideCents', v)} />
{/* Budget */}
預算
月預算
影響首頁的預算進度條與「預計月底」推算
NT$ { const n = Number(e.target.value.replace(/[^0-9]/g, '')) || 0; setBudget(n); }} />
{budgetError && (
{budgetError}
)}
{/* Data */}
資料管理
{/* TODO(PG M4): 「清除所有交易資料」/「重設分類為預設」之前只 mutate client state,不會落地到後端,誤導使用者。等 PG M4 補 bulk-delete endpoint + categories CRUD 再以伺服器端真實寫入重新露出。 */}
{/* About */}
關於
版本
Ledger 0.1 · 設計原型
); } function SettingsToggle({ value, onChange }) { return ( ); } // ────────────────────────────────────────────────────────────────────────────── // 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 (
{ setTab(next); if (next === 'ledger') { setCategoryFocus(null); setSubFilter(null); } if (next === 'recurring') { setRecurringView(null); } }} /> { setMonth(d); }} rangePreset={range?.preset ?? 'month'} onRangePreset={handleRangePreset} hasCustomRange={range?.preset === 'custom'} />
{tab === 'ledger' && (
setRange(null)} byDay={byDay} heatmap={t.heatmap} today={TODAY} pickingHint={range?.pending ? '再點一天完成範圍(可跨月)' : null} /> {range && !range.pending && (
{F.longDate(range.start)} {range.start.getTime() !== range.end.getTime() && ( <> - {F.longDate(range.end)} )}  共 {rangeTx.length} 筆 · NT$ {rangeTotal.toLocaleString('en-US')}
)} {range && range.pending && (
起點 {F.longDate(range.start)} — 等待選擇終點…
)}
setTab('categories')} />
{view === 'list' ? '交易明細' : '圖表'}
{view === 'list' ? `顯示 ${filtered.length} 筆` : `區間 ${rangeTx.length} 筆`} {range && ( {F.longDate(range.start)} {range.start.getTime() !== range.end.getTime() && ' - ' + F.longDate(range.end)} )} {filterCat && view === 'list' && {CATEGORIES.find(c => c.id === filterCat).name}} {search && view === 'list' && "{search}"} {(range || filterCat || search) && ( )}
{view === 'list' && ( )}
{view === 'list' && bulkMode && ( 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' && (
{cats.map((c) => { const ct = totals[c.id] || 0; if (ct === 0) return null; return ( ); })}
)} {view === 'list' ? ( setSelectedTxIds((cur) => { const next = new Set(cur); next.has(id) ? next.delete(id) : next.add(id); return next; })} /> ) : ( )}
)} {tab === 'projects' && !projectViewId && ( setProjectEditState({ mode: 'create' })} onOpenProject={(id) => setProjectViewId(id)} onEditProject={(p) => setProjectEditState({ mode: 'edit', project: p })} /> )} {tab === 'projects' && projectViewId && ( setProjectViewId(null)} /> )} {tab === 'recurring' && !recurringView && ( setRecurringEditState({ mode: 'create' })} onOpenRule={(id) => setRecurringView(id)} onEditRule={(r) => setRecurringEditState({ mode: 'edit', rule: r })} /> )} {tab === 'recurring' && recurringView && ( setRecurringView(null)} onEdit={(r) => setRecurringEditState({ mode: 'edit', rule: r })} onDelete={() => { setRecurringView(null); setRecurringRefreshKey((n) => n + 1); }} /> )} {tab === 'categories' && ( setShowCatManager(true)} /> )} {tab === 'settings' && ( setConfirm({ title: '登出?', body: '會結束目前 session,需要重新輸入密碼。', danger: false, confirmLabel: '登出', onConfirm: async () => { try { await window.LEDGER_API.apiLogout(); } catch (e) {} location.reload(); }, })} /> )}
{/* Keyboard hint — fades out after first interaction */}
/ 搜尋 N 新增 ← → 月份 Esc 清除
setEditing(null)} onSave={handleSave} onDelete={handleDelete} onDuplicate={handleDuplicate} projectsCatalog={projectsCatalog} /> setTweak('dark', v)} /> setTweak('accent', v)} /> setTweak('density', v)} /> setTweak('heatmap', v)} /> setTweak('hideCents', v)} /> {/* Manage categories overlay */} {showCatManager && ( setShowCatManager(false)} onConfirm={(opts) => setConfirm(opts)} onChanged={() => { window.location.reload(); }} /> )} {/* Project edit/create side panel */} {projectEditState && ( 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 && ( setRecurringEditState(null)} onSaved={() => { setRecurringEditState(null); setRecurringRefreshKey((n) => n + 1); }} onDelete={() => { setRecurringEditState(null); setRecurringView(null); setRecurringRefreshKey((n) => n + 1); }} /> )} {/* Generic confirm dialog */} {confirm && ( setConfirm(null)} /> )}
); } 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 (
Ledger
setPassword(e.target.value)} placeholder="輸入密碼" /> {error &&
{error}
}
); } // ────────────────────────────────────────────────────────────────────────────── // 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(); }; window.__renderLogin = function () { ReactDOM.createRoot(document.getElementById('root')).render(); };