// recurring.jsx — M4 Recurring (固定支出) feature components. // Loaded BEFORE app.jsx; exposes everything via window.Recurring. // // Babel-standalone: no imports/exports. Uses React globals + window.LEDGER_API. (function () { const { useState, useEffect, useMemo, useCallback } = React; // ── Local helpers ──────────────────────────────────────────────────── function _fmt(n) { const v = Number(n) || 0; return v.toLocaleString('en-US'); } function _parseDate(s) { if (!s) return null; const [y, m, d] = String(s).slice(0, 10).split('-').map(Number); if (!y || !m || !d) return null; return new Date(y, m - 1, d); } function _fmtMD(s) { const d = s instanceof Date ? s : _parseDate(s); if (!d) return ''; return (d.getMonth() + 1) + '/' + d.getDate(); } function _fmtYMD(s) { const d = s instanceof Date ? s : _parseDate(s); if (!d) return ''; return d.getFullYear() + '/' + (d.getMonth() + 1) + '/' + d.getDate(); } function _todayLocal() { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), n.getDate()); } function _todayIso() { const t = _todayLocal(); const mm = String(t.getMonth() + 1).padStart(2, '0'); const dd = String(t.getDate()).padStart(2, '0'); return t.getFullYear() + '-' + mm + '-' + dd; } function _dayDelta(target) { const d = target instanceof Date ? target : _parseDate(target); if (!d) return null; const t = new Date(d); t.setHours(0, 0, 0, 0); const n = _todayLocal(); return Math.round((t - n) / 86400000); } function _relativeDay(target) { const d = _dayDelta(target); if (d == null) return ''; if (d === 0) return '今天'; if (d === 1) return '明天'; if (d === -1) return '昨天'; if (d > 0) return d + ' 天後'; return Math.abs(d) + ' 天前'; } function _weekday(target) { const d = target instanceof Date ? target : _parseDate(target); if (!d) return ''; return '日一二三四五六'[d.getDay()]; } function _scheduleText(schedule) { if (!schedule) return ''; if (schedule.type === 'monthly') { const days = (schedule.days || []).slice().sort((a, b) => a - b); return '每月 ' + days.join('、') + ' 日'; } if (schedule.type === 'interval') { return '每 ' + schedule.days + ' 天'; } return ''; } function _catSlug(categories, categoryId) { if (!Array.isArray(categories) || categoryId == null) return 'other'; const found = categories.find((c) => c.id === categoryId); return (found && found.slug) || 'other'; } function _catName(categories, categoryId) { if (!Array.isArray(categories) || categoryId == null) return ''; const found = categories.find((c) => c.id === categoryId); return (found && found.name) || ''; } function _subName(categories, categoryId, subId) { if (!Array.isArray(categories) || categoryId == null || subId == null) return ''; const cat = categories.find((c) => c.id === categoryId); if (!cat) return ''; const sub = (cat.sub || []).find((s) => s.id === subId); return (sub && sub.name) || ''; } // ── RuleTimeline ───────────────────────────────────────────────────── function RuleTimeline({ rule }) { const past = rule.last_fired_at ? [rule.last_fired_at] : []; const next = rule.enabled && rule.next_run ? [rule.next_run] : []; if (!past.length && !next.length) { return (
紀錄 尚無自動產生
); } return (
紀錄 {past.map((d, i) => ( ))} {next.map((d, i) => ( {i < next.length - 1 && } ))}
); } // ── RuleCard ───────────────────────────────────────────────────────── function RuleCard({ rule, categories, onOpen, onEdit, onToggle, toggleBusy }) { const slug = _catSlug(categories, rule.category_id); const catName = _catName(categories, rule.category_id); const subName = _subName(categories, rule.category_id, rule.subcategory_id); const isOff = !rule.enabled; const delta = rule.enabled && rule.next_run ? _dayDelta(rule.next_run) : null; const isSoon = delta != null && delta >= 0 && delta <= 3; return (
{rule.name}
{_scheduleText(rule.schedule)} {(catName || subName) && ( <> · {catName}{subName ? '/' + subName : ''} )}
{ e.stopPropagation(); if (toggleBusy) return; // drop click while in-flight if (onToggle) onToggle(); }} />
NT$ {_fmt(rule.amount)}
{rule.enabled && rule.next_run ? '下次 ' + _fmtMD(rule.next_run) : '已停用'} {rule.enabled && rule.next_run && ( {_relativeDay(rule.next_run)} )}
{(rule.fired_count > 0) && (
已自動產生 {rule.fired_count} 筆 · 合計 NT$ {_fmt(rule.fired_total)}
)}
); } // ── RecurringPage (list view) ──────────────────────────────────────── function RecurringPage({ refreshKey, onCreateRule, onOpenRule, onEditRule }) { const [rules, setRules] = useState([]); const [stats, setStats] = useState(null); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showOff, setShowOff] = useState(true); const [healthOpen, setHealthOpen] = useState(false); const [health, setHealth] = useState(null); // Per-rule in-flight guard. Repeated clicks on the same switch // before the previous /toggle round-trip completes would otherwise // fan out concurrent POSTs against a stale `rule.enabled` value. const [togglingIds, setTogglingIds] = useState(() => new Set()); const reload = useCallback(() => { setLoading(true); setError(null); const api = window.LEDGER_API; return Promise.all([ api.apiListRecurring(), api.apiRecurringStats(), api.apiCategoriesFull(), ]) .then(([rs, st, cats]) => { setRules(rs); setStats(st); setCategories(cats); setLoading(false); }) .catch((err) => { setError(err); setLoading(false); }); }, []); useEffect(() => { let cancelled = false; setLoading(true); setError(null); const api = window.LEDGER_API; Promise.all([ api.apiListRecurring(), api.apiRecurringStats(), api.apiCategoriesFull(), ]) .then(([rs, st, cats]) => { if (cancelled) return; setRules(rs); setStats(st); setCategories(cats); setLoading(false); }) .catch((err) => { if (cancelled) return; setError(err); setLoading(false); }); return () => { cancelled = true; }; }, [refreshKey]); const handleToggle = useCallback(async (rule) => { // Drop the click if a previous toggle for this rule is still in // flight — server already accepted one state change; the second // would post a stale rule.enabled and could end up wrong. let alreadyToggling = false; setTogglingIds((prev) => { if (prev.has(rule.id)) { alreadyToggling = true; return prev; } const next = new Set(prev); next.add(rule.id); return next; }); if (alreadyToggling) return; try { await window.LEDGER_API.apiToggleRecurring(rule.id, !rule.enabled); await reload(); } catch (err) { alert('切換失敗:' + (err.message || String(err))); } finally { setTogglingIds((prev) => { if (!prev.has(rule.id)) return prev; const next = new Set(prev); next.delete(rule.id); return next; }); } }, [reload]); const openHealth = useCallback(async () => { try { const h = await window.LEDGER_API.apiRecurringHealth(); setHealth(h); setHealthOpen(true); } catch (err) { alert('載入健康狀態失敗:' + (err.message || String(err))); } }, []); const active = useMemo(() => rules.filter((r) => r.enabled), [rules]); const inactive = useMemo(() => rules.filter((r) => !r.enabled), [rules]); return (
RECURRING · 自動記帳

固定支出

系統會依規則自動寫入記錄 · 與 Telegram 通知連動
{stats && (
本月已自動產生
NT$ {_fmt(stats.this_month_fired_total || 0)}
{stats.this_month_fired_count || 0} 筆 · 涵蓋 {active.length} 條規則
下次自動記帳
{stats.next_run ? _fmtMD(stats.next_run.next_run) : '—'} {stats.next_run && ( {_relativeDay(stats.next_run.next_run)} )}
{stats.next_run ? stats.next_run.name + ' · NT$ ' + _fmt(stats.next_run.amount) : '無啟用規則'}
每月預估支出
NT$ {_fmt(stats.expected_monthly || 0)}
依 {active.length} 條啟用規則推算
)} {loading && (
載入中…
)} {error && (
載入失敗:{error.message || String(error)}
)} {!loading && !error && ( <>
啟用中 {active.length} 條
點規則卡查看歷史紀錄 · 切換開關暫停 / 啟用
{active.length === 0 ? (
尚未建立規則
) : (
{active.map((r) => ( onOpenRule && onOpenRule(r.id)} onEdit={() => onEditRule && onEditRule(r)} onToggle={() => handleToggle(r)} toggleBusy={togglingIds.has(r.id)} /> ))}
)} {inactive.length > 0 && (
{inactive.length} 條
停用中不會自動產生紀錄;可隨時切回啟用
{showOff && (
{inactive.map((r) => ( onOpenRule && onOpenRule(r.id)} onEdit={() => onEditRule && onEditRule(r)} onToggle={() => handleToggle(r)} /> ))}
)}
)} )} {healthOpen && health && ( setHealthOpen(false)} /> )}
); } // ── HealthModal ────────────────────────────────────────────────────── function HealthModal({ health, onClose }) { return (
e.stopPropagation()}>
排程健康狀態
排程啟用:{health.scheduler_enabled ? '是' : '否'}
排程運行中:{health.scheduler_running ? '是' : '否'}
最近 tick:{health.last_tick_at || '—'}
最近成功:{health.last_success_at || '—'}
最近錯誤:{health.last_error || '—'}
上次處理 / 產生:{health.last_processed_count || 0} / {health.last_created_count || 0}
上次耗時:{health.last_duration_ms || 0} ms
待 fire 規則數:{health.next_due_count || 0}
最早待 fire:{health.oldest_due_next_run || '—'}
); } // ── RuleEditPanel ──────────────────────────────────────────────────── function RuleEditPanel({ mode, rule, categories: catsProp, onClose, onSaved, onDelete }) { const isEdit = mode === 'edit' && rule; // Categories prop may be the lightweight {id:name, slug, name, sub} shape // from app.jsx — for create/edit we need int IDs, so fetch the full list. const [categories, setCategories] = useState([]); useEffect(() => { let cancelled = false; window.LEDGER_API.apiCategoriesFull() .then((cats) => { if (!cancelled) setCategories(cats); }) .catch(() => { if (!cancelled) setCategories([]); }); return () => { cancelled = true; }; }, []); // Initial state pulled from rule when editing. const initSched = isEdit ? rule.schedule : { type: 'monthly', days: [_todayLocal().getDate()] }; const [name, setName] = useState(isEdit ? rule.name : ''); const [scheduleKind, setScheduleKind] = useState(initSched.type || 'monthly'); const [monthlyDays, setMonthlyDays] = useState(() => { if (initSched.type === 'monthly') return new Set(initSched.days || []); return new Set([_todayLocal().getDate()]); }); const [intervalDays, setIntervalDays] = useState(() => { if (initSched.type === 'interval') return initSched.days || 7; return 7; }); const [startDate, setStartDate] = useState(isEdit ? rule.start_date : _todayIso()); const [endDate, setEndDate] = useState(isEdit && rule.end_date ? rule.end_date : ''); const [amount, setAmount] = useState(isEdit ? String(rule.amount || '') : ''); const [categoryId, setCategoryId] = useState(isEdit ? rule.category_id : null); const [subId, setSubId] = useState(isEdit ? rule.subcategory_id : null); const [item, setItem] = useState(isEdit ? (rule.item || '') : ''); const [note, setNote] = useState(isEdit ? (rule.note || '') : ''); const [enabled, setEnabled] = useState(isEdit ? !!rule.enabled : true); const [notify, setNotify] = useState(isEdit ? !!rule.notify : true); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const activeCat = categories.find((c) => c.id === categoryId); const hasRiskyDay = scheduleKind === 'monthly' && [29, 30, 31].some((d) => monthlyDays.has(d)); const toggleDay = (d) => { setMonthlyDays((cur) => { const next = new Set(cur); if (next.has(d)) next.delete(d); else next.add(d); return next; }); }; // When categories load and we're creating from scratch, pre-pick first. useEffect(() => { if (!categories.length) return; if (categoryId == null) { const first = categories[0]; setCategoryId(first.id); if (first.sub && first.sub.length) setSubId(first.sub[0].id); } else if (subId == null && activeCat && activeCat.sub && activeCat.sub.length) { setSubId(activeCat.sub[0].id); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [categories]); const buildPayload = () => { const trimmed = name.trim(); if (!trimmed) throw new Error('請輸入名稱'); if (!amount || Number(amount) < 0) throw new Error('請輸入金額'); if (categoryId == null) throw new Error('請選擇分類'); let schedule; if (scheduleKind === 'monthly') { const days = Array.from(monthlyDays).sort((a, b) => a - b); if (!days.length) throw new Error('請選擇至少一個月份日期'); schedule = { type: 'monthly', days }; } else { const n = parseInt(intervalDays, 10); if (!n || n < 1 || n > 365) throw new Error('間隔天數必須是 1-365'); schedule = { type: 'interval', days: n }; } const payload = { name: trimmed, schedule, start_date: startDate || _todayIso(), amount: Number(amount), category_id: categoryId, item: item.trim(), note: note.trim(), enabled, notify, }; // end_date and subcategory_id are nullable on the server. In edit // mode we must send explicit JSON null when the user cleared the // field, otherwise RulePatch leaves the old value untouched and // the UI silently drops the user's intent. Create mode treats // missing == null so we can keep the payload tight there. if (isEdit) { payload.end_date = endDate || null; payload.subcategory_id = subId != null ? subId : null; } else { if (subId != null) payload.subcategory_id = subId; if (endDate) payload.end_date = endDate; } return payload; }; const save = async () => { if (busy) return; setBusy(true); setErr(null); try { const payload = buildPayload(); let saved; if (isEdit) { saved = await window.LEDGER_API.apiUpdateRecurring(rule.id, payload); } else { saved = await window.LEDGER_API.apiCreateRecurring(payload); } if (onSaved) onSaved(saved); } catch (e) { setErr(e.message || String(e)); setBusy(false); } }; const remove = async () => { if (!isEdit) return; if (!window.confirm('刪除此規則?歷史紀錄會保留但不再標記為自動。')) return; setBusy(true); try { await window.LEDGER_API.apiDeleteRecurring(rule.id); if (onDelete) onDelete(rule.id); } catch (e) { setErr(e.message || String(e)); setBusy(false); } }; return (
e.stopPropagation()}>
{isEdit ? '編輯規則' : '新增規則'}
名稱 setName(e.target.value)} placeholder="例:Netflix、健身房月費" autoFocus />
排程
{scheduleKind === 'monthly' && ( <>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => ( ))}
{hasRiskyDay && (
29 / 30 / 31 日:2、4、6、9、11 月該日不存在時自動跳到下一個合法日,不會退為該月最後一日。
)} )} {scheduleKind === 'interval' && (
setIntervalDays(+e.target.value || 1)} /> 天 fire 一次 · 從 start_date 起算
)}
開始日期 setStartDate(e.target.value)} />
結束日期(可選) setEndDate(e.target.value)} />
空白代表永久執行 · 結束日當天仍會 fire 一次
金額
NT$ setAmount(e.target.value.replace(/[^\d.]/g, ''))} placeholder="0" />
分類
{categories.map((c) => ( ))}
{activeCat && activeCat.sub && activeCat.sub.length > 0 && ( <> 子分類
{activeCat.sub.map((s) => ( ))}
)}
品項(寫入 record) setItem(e.target.value)} placeholder="例:長榮街公寓、Netflix 高級方案" />
備註 setNote(e.target.value)} placeholder="可選" />
啟用 關閉後不會自動產生紀錄,但歷史紀錄保留
setEnabled((v) => !v)} />
Telegram 通知 每次自動記帳後,bot 會發送該筆摘要
setNotify((v) => !v)} />
{err &&
錯誤:{err}
}
{isEdit && ( )}
); } // ── RuleDetailView ─────────────────────────────────────────────────── function RuleDetailView({ ruleId, refreshKey, onBack, onEdit, onDelete }) { const [rule, setRule] = useState(null); const [history, setHistory] = useState([]); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); setError(null); const api = window.LEDGER_API; Promise.all([ api.apiGetRecurring(ruleId), api.apiListRuleRecords(ruleId, 50), api.apiCategoriesFull(), ]) .then(([r, recs, cats]) => { if (cancelled) return; setRule(r); setHistory(recs); setCategories(cats); setLoading(false); }) .catch((err) => { if (cancelled) return; setError(err); setLoading(false); }); return () => { cancelled = true; }; }, [ruleId, refreshKey]); // Per-request guard: drop the click while a previous toggle on this // rule is in flight; the second call would post a stale rule.enabled. const [toggling, setToggling] = useState(false); const handleToggle = async () => { if (!rule || toggling) return; setToggling(true); try { const updated = await window.LEDGER_API.apiToggleRecurring(rule.id, !rule.enabled); setRule(updated); } catch (err) { alert('切換失敗:' + (err.message || String(err))); } finally { setToggling(false); } }; const handleDelete = async () => { if (!rule) return; if (!window.confirm('刪除此規則?歷史紀錄會保留但不再標記為自動。')) return; try { await window.LEDGER_API.apiDeleteRecurring(rule.id); if (onDelete) onDelete(rule.id); } catch (err) { alert('刪除失敗:' + (err.message || String(err))); } }; if (loading) { return (
載入中…
); } if (error || !rule) { return (
載入失敗:{error ? (error.message || String(error)) : '找不到規則'}
); } const slug = _catSlug(categories, rule.category_id); const catName = _catName(categories, rule.category_id); const subName = _subName(categories, rule.category_id, rule.subcategory_id); // Average interval days — only meaningful if we know first_fire_at and // there have been at least 2 fires. let avgInterval = '—'; if (rule.first_fire_at && rule.fired_count > 1) { const first = _parseDate(rule.first_fire_at); const today = _todayLocal(); const spanDays = Math.max(0, Math.round((today - first) / 86400000)); const per = spanDays / (rule.fired_count - 1); if (per > 0 && Number.isFinite(per)) avgInterval = per.toFixed(1) + ' 天'; } return (
{rule.name}
{_scheduleText(rule.schedule)} {(catName || subName) && ( <> · {catName}{subName ? '/' + subName : ''} )} {rule.item && ( <> · {rule.item} )}
NT$ {_fmt(rule.amount)}
{rule.enabled && rule.next_run && (
下次自動記帳 {_fmtMD(rule.next_run)} {_relativeDay(rule.next_run)}
)}
已自動產生 {rule.fired_count || 0} 筆 {rule.first_fire_at ? '最早 ' + _fmtYMD(rule.first_fire_at) : '尚未 fire'}
合計金額 NT${_fmt(rule.fired_total || 0)} 所有自動產生紀錄
平均週期 {avgInterval} 由首次至今
通知 {rule.notify ? '已開' : '已關'} Telegram bot
產生紀錄
共 {history.length} 筆
{history.length === 0 && (
尚無自動產生紀錄
)} {history.map((h) => (
{_fmtYMD(h.date)} {_weekday(h.date)} {_dayDelta(h.date) != null && ( <> · {_dayDelta(h.date) === 0 ? '今天' : Math.abs(_dayDelta(h.date)) + ' 天前'} )}
{h.item || rule.item || rule.name} {h.note && · {h.note}} {h.auto !== false && ⤿ 自動}
{catName}{h.sub_category ? '/' + h.sub_category : ''}
NT${_fmt(h.amount)}
))}
); } // ── Export to global ───────────────────────────────────────────────── window.Recurring = window.Recurring || {}; Object.assign(window.Recurring, { RecurringPage, RuleCard, RuleEditPanel, RuleDetailView, HealthModal, }); })();