// 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) => (
pickPreset(col)}
title={col.toUpperCase()}
/>
))}
setOpen(!open)}
title="自訂顏色"
>
{!isPresetMatch && (
)}
{isPresetMatch && + }
最近使用
{recent.map((c) => (
pickPreset(c)}
title={c.toUpperCase()}
/>
))}
{Array.from({ length: emptySlots }).map((_, i) => (
))}
{open && (
)}
);
}
// ── 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 && <>· 已封存 >}
{ e.stopPropagation(); onEdit(); }}
>
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 · 時間性容器
專案
把分散在多分類的支出按主題集中追蹤 · 一筆記帳可同時屬於多個專案
+ 新增專案
setTab('active')}
>進行中{active.length}
setTab('archived')}
>已封存{archived.length}
點卡片查看單一專案明細 · 進度條使用 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 ? '編輯專案' : '新增專案'}
{isEdit && !archived && (
封存專案
)}
{isEdit && archived && (
取消封存
)}
取消
{isEdit ? '儲存變更' : '建立專案'}
);
}
// ── 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} 筆
{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}
{ e.stopPropagation(); remove(p.id); }}
>✕
))}
{ 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) => (
e.preventDefault()}
onClick={() => add(p.id)}
>
{p.name}
))}
)}
);
}
// ── Export to global ──────────────────────────────────────────────────
window.Projects = window.Projects || {};
Object.assign(window.Projects, {
ColorPicker,
PRESET_PROJECT_COLORS,
PRESET_CATEGORY_COLORS,
ProjectsPage,
ProjectCard,
ProjectEditPanel,
ProjectDetailView,
ProjectPicker,
});
})();