// projects.jsx — Projects feature components, loaded BEFORE app.jsx // Exposes everything via window.Projects so app.jsx can reach it. // // Babel-standalone: no imports/exports. Use React globals. (function () { const { useState, useEffect, useRef, useCallback } = React; // ── Color presets ───────────────────────────────────────────────────── // Ported from new_tab_UI/new-screens-detail.jsx (lines 766-773). // PRESET_CATEGORY_COLORS must match app.jsx COLOR_OPTIONS (line 2054) exactly. const PRESET_PROJECT_COLORS = [ '#2a6fdb', '#7c3aed', '#b54a8f', '#3a8a6b', '#d96a3a', '#c0a51f', '#7a7a7a', ]; const PRESET_CATEGORY_COLORS = [ '#0066cc', '#d96a3a', '#2a6fdb', '#b54a8f', '#c0a51f', '#3a8a6b', '#cc4747', '#7c3aed', '#7a7a7a', ]; // ── HSV ↔ RGB ↔ Hex helpers ─────────────────────────────────────────── function _hexToRgb(hex) { let h = String(hex || '').replace('#', ''); // No 3-char shorthand support per README spec — but tolerate it from // an external `value` prop by expanding it (don't reject silently). if (h.length === 3) h = h.split('').map((c) => c + c).join(''); return { r: parseInt(h.slice(0, 2), 16) || 0, g: parseInt(h.slice(2, 4), 16) || 0, b: parseInt(h.slice(4, 6), 16) || 0, }; } function _rgbToHex({ r, g, b }) { const h = (n) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0'); return '#' + h(r) + h(g) + h(b); } function _rgbToHsv({ r, g, b }) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); const d = max - min; let h = 0; if (d !== 0) { if (max === r) h = ((g - b) / d) % 6; else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; } h = (h * 60 + 360) % 360; const s = max === 0 ? 0 : (d / max) * 100; const v = max * 100; return { h, s, v }; } function _hsvToRgb({ h, s, v }) { s /= 100; v /= 100; const c = v * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = v - c; let r, g, b; if (h < 60) [r, g, b] = [c, x, 0]; else if (h < 120) [r, g, b] = [x, c, 0]; else if (h < 180) [r, g, b] = [0, c, x]; else if (h < 240) [r, g, b] = [0, x, c]; else if (h < 300) [r, g, b] = [x, 0, c]; else [r, g, b] = [c, 0, x]; return { r: (r + m) * 255, g: (g + m) * 255, b: (b + m) * 255 }; } // ── Recent swatches persistence ─────────────────────────────────────── const RECENT_CAP = 6; function _loadRecent(key) { try { const raw = window.localStorage && window.localStorage.getItem(key); if (!raw) return []; const arr = JSON.parse(raw); if (!Array.isArray(arr)) return []; return arr .filter((c) => typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c)) .slice(0, RECENT_CAP); } catch (e) { return []; } } function _saveRecent(key, arr) { try { window.localStorage && window.localStorage.setItem(key, JSON.stringify(arr)); } catch (e) { /* ignore quota / disabled storage */ } } function _pushRecent(cur, hex) { const norm = String(hex || '').toLowerCase(); if (!/^#[0-9a-f]{6}$/.test(norm)) return cur; const filtered = cur.filter((c) => c.toLowerCase() !== norm); return [norm, ...filtered].slice(0, RECENT_CAP); } // ── Pointer drag helper ─────────────────────────────────────────────── // Attaches pointermove/pointerup to document; detaches on pointerup. // Returns the pointerdown handler. function _makeDragHandler(elRef, onUpdate) { return (e) => { if (!elRef.current) return; e.preventDefault(); const update = (ev) => { const rect = elRef.current.getBoundingClientRect(); if (!rect.width || !rect.height) return; const x = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width)); const y = Math.max(0, Math.min(1, (ev.clientY - rect.top) / rect.height)); onUpdate(x, y); }; update(e); const move = (ev) => update(ev); const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); document.removeEventListener('pointercancel', up); }; document.addEventListener('pointermove', move); document.addEventListener('pointerup', up); document.addEventListener('pointercancel', up); }; } // ── ColorPicker component ───────────────────────────────────────────── function ColorPicker({ value, onChange, presets, initialOpen = false, recentKey = 'ledger.colorpicker.recent.v1', }) { const presetList = Array.isArray(presets) && presets.length ? presets : PRESET_PROJECT_COLORS; const [open, setOpen] = useState(initialOpen); const [hsv, setHsv] = useState(() => _rgbToHsv(_hexToRgb(value))); const [recent, setRecent] = useState(() => _loadRecent(recentKey)); const padRef = useRef(null); const hueRef = useRef(null); // Derived const rgb = _hsvToRgb(hsv); const hex = _rgbToHex(rgb); // Sync external value -> internal hsv when prop changes externally. useEffect(() => { const lower = String(value || '').toLowerCase(); if (/^#[0-9a-f]{6}$/.test(lower) && lower !== hex.toLowerCase()) { setHsv(_rgbToHsv(_hexToRgb(value))); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); const commit = useCallback( (newHex) => { if (typeof onChange === 'function') onChange(newHex); }, [onChange] ); // Push to recents (used when user "picks" a color: preset, recent, or // pressing 完成 on the custom panel). Not called on every HSV-drag tick. const recordRecent = useCallback( (newHex) => { setRecent((cur) => { const next = _pushRecent(cur, newHex); if (next !== cur) _saveRecent(recentKey, next); return next; }); }, [recentKey] ); const startPadDrag = _makeDragHandler(padRef, (x, y) => { setHsv((cur) => { const next = { ...cur, s: x * 100, v: (1 - y) * 100 }; commit(_rgbToHex(_hsvToRgb(next))); return next; }); }); const startHueDrag = _makeDragHandler(hueRef, (x) => { setHsv((cur) => { const next = { ...cur, h: x * 360 }; commit(_rgbToHex(_hsvToRgb(next))); return next; }); }); const setHexInput = (s) => { const cleaned = '#' + s.replace(/[^0-9a-f]/gi, '').slice(0, 6); if (cleaned.length === 7) { setHsv(_rgbToHsv(_hexToRgb(cleaned))); commit(cleaned); } }; const setRgbComponent = (comp, val) => { const n = Math.max(0, Math.min(255, parseInt(val, 10) || 0)); const newRgb = { ...rgb, [comp]: n }; setHsv(_rgbToHsv(newRgb)); commit(_rgbToHex(newRgb)); }; const pickPreset = (col) => { const norm = String(col || '').toLowerCase(); setHsv(_rgbToHsv(_hexToRgb(norm))); commit(norm); recordRecent(norm); }; const finishCustom = () => { recordRecent(hex); setOpen(false); }; const isPresetMatch = presetList.some((p) => p.toLowerCase() === hex.toLowerCase()); const hueDeg = Math.round(hsv.h); const emptySlots = Math.max(0, RECENT_CAP - recent.length); return (
預設色 已選 {hex.toUpperCase()}
{presetList.map((col) => (
最近使用
{recent.map((c) => (
{open && (
HEX setHexInput(e.target.value)} spellCheck={false} maxLength={6} />
{['r', 'g', 'b'].map((comp) => (
{comp.toUpperCase()} setRgbComponent(comp, e.target.value)} />
))}
拖曳 ◯ 選飽和度/亮度 · 下方條選色相
)}
); } // ── Number formatting (NT$ #,###) ───────────────────────────────────── function _fmt(n) { const v = Number(n) || 0; return v.toLocaleString('en-US'); } function _safeBudget(s) { if (s == null) return 0; const n = parseFloat(s); return Number.isFinite(n) ? n : 0; } function _fmtMD(d) { if (!d) return ''; const dt = d instanceof Date ? d : new Date(d); return (dt.getMonth() + 1) + '/' + dt.getDate(); } // ── Sparkline (12-month series) ─────────────────────────────────────── function ProjSpark({ data }) { const W = 100, H = 28; const arr = (data && data.length) ? data : [0]; const max = Math.max(1, ...arr); const stepX = W / Math.max(1, arr.length - 1); const pts = arr.map((v, i) => ({ x: (i * stepX).toFixed(1), y: (H - (v / max) * (H - 4) - 2).toFixed(1), })); const path = pts.map((p, i) => (i ? 'L' : 'M') + p.x + ' ' + p.y).join(' '); const area = path + ' L ' + W + ' ' + H + ' L 0 ' + H + ' Z'; const last = pts[pts.length - 1]; return ( ); } // ── ProjectCard ─────────────────────────────────────────────────────── function ProjectCard({ project: p, onOpen, onEdit }) { const budget = _safeBudget(p.target_budget); const spent = Number(p.spent_total) || 0; const noBudget = budget <= 0; const overBudget = !noBudget && spent > budget; const pct = !noBudget ? Math.min(100, Math.round((spent / budget) * 100)) : 0; const overPct = !noBudget ? Math.round((spent / budget) * 100) : 0; const archived = !!p.archived_at; return (
{p.name}
{p.records_count || 0} 筆紀錄 {p.last_tx_at && ( <> · 最近 {_fmtMD(p.last_tx_at)} )} {archived && <>·已封存}
NT$ {_fmt(spent)} {budget > 0 && ( / {_fmt(budget)} 預算 )} {noBudget && 未設預算}
{noBudget ? '無上限追蹤' : <>已用 {overPct}%} {budget > 0 && !overBudget && <>剩 NT$ {_fmt(budget - spent)}} {overBudget && <>超支 NT$ {_fmt(spent - budget)}}
看明細 →
); } // ── ProjectsPage (list view) ────────────────────────────────────────── function ProjectsPage({ onOpenProject, onCreateProject, onEditProject, refreshKey }) { const [tab, setTab] = useState('active'); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); window.LEDGER_API.apiListProjects(true) .then((rows) => { if (!cancelled) { setProjects(rows); setLoading(false); } }) .catch((err) => { if (!cancelled) { setError(err); setLoading(false); } }); return () => { cancelled = true; }; }, [refreshKey]); const active = projects.filter((p) => !p.archived_at); const archived = projects.filter((p) => p.archived_at); const shown = tab === 'active' ? active : archived; return (
PROJECTS · 時間性容器

專案

把分散在多分類的支出按主題集中追蹤 · 一筆記帳可同時屬於多個專案
點卡片查看單一專案明細 · 進度條使用 lifetime budget
{loading &&
載入中…
} {error &&
載入失敗:{error.message || String(error)}
} {!loading && !error && (
{shown.map((p) => ( onOpenProject(p.id)} onEdit={() => onEditProject(p)} /> ))}
)} {!loading && !error && shown.length === 0 && (
沒有{tab === 'active' ? '進行中' : '已封存'}的專案
)}
); } // ── ProjectEditPanel (side drawer) ──────────────────────────────────── function ProjectEditPanel({ mode, project, onClose, onSaved }) { const isEdit = mode === 'edit' && project; const [name, setName] = useState(isEdit ? project.name : ''); const [color, setColor] = useState(isEdit && project.color ? project.color : '#2a6fdb'); // Preserve cents from DB NUMERIC(12,2) — don't Math.round here, that // silently truncates 123.45 → 123 on every re-save. const [budget, setBudget] = useState( isEdit && project.target_budget != null ? String(project.target_budget).replace(/\.00$/, '') : '', ); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const save = useCallback(async () => { setBusy(true); setErr(null); try { const trimmed = name.trim(); if (!trimmed) { setErr('請輸入名稱'); setBusy(false); return; } const payload = { name: trimmed, color: color || null, target_budget: budget ? Number(budget) : null, }; let saved; if (isEdit) { saved = await window.LEDGER_API.apiUpdateProject(project.id, payload); } else { saved = await window.LEDGER_API.apiCreateProject(payload); } onSaved(saved); } catch (e) { setErr(e.message || String(e)); setBusy(false); } }, [name, color, budget, isEdit, project, onSaved]); const archive = useCallback(async () => { if (!isEdit) return; setBusy(true); try { const saved = await window.LEDGER_API.apiArchiveProject(project.id); onSaved(saved); } catch (e) { setErr(e.message || String(e)); setBusy(false); } }, [isEdit, project, onSaved]); const unarchive = useCallback(async () => { if (!isEdit) return; setBusy(true); try { const saved = await window.LEDGER_API.apiUnarchiveProject(project.id); onSaved(saved); } catch (e) { setErr(e.message || String(e)); setBusy(false); } }, [isEdit, project, onSaved]); const archived = isEdit && project.archived_at; return (
{isEdit ? '編輯專案' : '新增專案'}
名稱 setName(e.target.value)} placeholder="例:日本旅行 2026、婚禮籌備" autoFocus />
在 record 上多選一個或多個專案來把支出歸戶
顏色
目標預算(lifetime · 可選)
NT$ { // Allow digits + one decimal point (NUMERIC(12,2) cents). let v = e.target.value.replace(/[^\d.]/g, ''); const i = v.indexOf('.'); if (i !== -1) v = v.slice(0, i + 1) + v.slice(i + 1).replace(/\./g, ''); setBudget(v); }} placeholder="0 = 不設預算" />
Lifetime budget:專案總花費的目標上限
{err &&
錯誤:{err}
}
{isEdit && !archived && ( )} {isEdit && archived && ( )}
); } // ── ProjectDetailView ────────────────────────────────────────────────── function ProjectDetailView({ projectId, onBack, refreshKey }) { const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); window.LEDGER_API.apiGetProjectSummary(projectId) .then((s) => { if (!cancelled) { setSummary(s); setLoading(false); } }) .catch((e) => { if (!cancelled) { setError(e); setLoading(false); } }); return () => { cancelled = true; }; }, [projectId, refreshKey]); if (loading) { return (
載入中…
); } if (error || !summary) { return (
載入失敗
); } const p = summary.project; const budget = _safeBudget(p.target_budget); const spent = Number(p.spent_total) || 0; const noBudget = budget <= 0; const pct = !noBudget ? Math.min(100, Math.round((spent / budget) * 100)) : 0; const max = Math.max(1, ...summary.monthly_series.map((m) => m.total)); const currentMonth = new Date().getMonth() + 1; const currentYear = new Date().getFullYear(); return (
{p.name}
{p.records_count} 筆紀錄 {p.last_tx_at && <>·最近 {p.last_tx_at}}
NT$ {_fmt(spent)} {budget > 0 && / NT$ {_fmt(budget)} 預算}
{budget > 0 && ( <>
已用 {pct}% 剩 NT$ {_fmt(Math.max(0, budget - spent))}
)}
月均 NT${_fmt(Math.round(summary.stats.monthly_avg))} 有支出的月份平均
最高單月 NT${_fmt(Math.round(summary.stats.max_month.total))} {summary.stats.max_month.year} / {summary.stats.max_month.month} 月
最大單筆 NT${_fmt(Math.round(summary.stats.max_record.amount))} {summary.stats.max_record.item || '—'}
平均單筆 NT${_fmt(Math.round(summary.stats.avg_record))} 共 {p.records_count} 筆
月度走勢
近 12 個月
{summary.monthly_series.map((m, i) => { const isCurrent = m.year === currentYear && m.month === currentMonth; return (
); })}
{summary.monthly_series.map((m, i) => (
{m.month}月
))}
最近紀錄
{summary.recent_records.length === 0 && (
尚無紀錄
)} {summary.recent_records.map((r, i) => (
{_fmtMD(r.date)} {r.item} {r.category}{r.sub_category ? ' · ' + r.sub_category : ''} NT$ {_fmt(r.amount)}
))}
); } // ── ProjectPicker (multi-select chip for RecordForm) ────────────────── function ProjectPicker({ value, onChange, projects }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const inputRef = useRef(null); const selected = (value || []).map((id) => (projects || []).find((p) => p.id === id), ).filter(Boolean); const active = (projects || []).filter( (p) => !p.archived_at && !(value || []).includes(p.id), ); const filtered = query ? active.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())) : active; const add = (id) => { onChange([...(value || []), id]); setQuery(''); setOpen(false); }; const remove = (id) => onChange((value || []).filter((x) => x !== id)); return (
{ setOpen(true); inputRef.current && inputRef.current.focus(); }}> {selected.map((p) => ( {p.name} ))} { setQuery(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 120)} placeholder={selected.length ? '' : '加入專案…'} />
{open && filtered.length > 0 && (
{filtered.slice(0, 8).map((p) => ( ))}
)}
); } // ── Export to global ────────────────────────────────────────────────── window.Projects = window.Projects || {}; Object.assign(window.Projects, { ColorPicker, PRESET_PROJECT_COLORS, PRESET_CATEGORY_COLORS, ProjectsPage, ProjectCard, ProjectEditPanel, ProjectDetailView, ProjectPicker, }); })();