// 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 起算
)}
空白代表永久執行 · 結束日當天仍會 fire 一次
分類
{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,
});
})();