// Trends page — month-over-month with range selector + per-category time-series
function TrendsPage() {
const allMonths = D.months || [];
const [range, setRange] = useState('12'); // '3' | '6' | '12' | 'all'
const [selectedCat, setSelectedCat] = useState(null);
const [a, setA] = useState(Math.max(0, allMonths.length - 2));
const [b, setB] = useState(Math.max(0, allMonths.length - 1));
// Filter months by selected range — always anchored to most recent
const months = useMemo(() => {
if (range === 'all') return allMonths;
const n = parseInt(range, 10);
return allMonths.slice(-n);
}, [range, allMonths.length]);
// Aggregate stats across the visible range
const totalExp = months.reduce((s, m) => s + (m.summary?.expenses || 0), 0);
const totalInc = months.reduce((s, m) => s + (m.summary?.income || 0), 0);
const avgExp = months.length ? totalExp / months.length : 0;
const maxMonth = months.reduce((max, m) => (!max || m.summary.expenses > max.summary.expenses ? m : max), null);
const minMonth = months.reduce((min, m) => (!min || m.summary.expenses < min.summary.expenses ? m : min), null);
// All categories that appear in the range, with totals
const catTotals = useMemo(() => {
const totals = {};
months.forEach(m => {
Object.entries(m.summary?.byCat || {}).forEach(([c, v]) => {
totals[c] = (totals[c] || 0) + v;
});
});
return Object.entries(totals).sort((a, b) => b[1] - a[1]);
}, [months]);
// Per-month series for selected category (or first cat by default)
const activeCat = selectedCat || catTotals[0]?.[0];
const catSeries = useMemo(() => {
if (!activeCat) return [];
return months.map(m => ({
month: m,
value: m.summary?.byCat?.[activeCat] || 0,
}));
}, [months, activeCat]);
const catMax = Math.max(...catSeries.map(p => p.value), 1);
const catAvg = catSeries.length ? catSeries.reduce((s, p) => s + p.value, 0) / catSeries.length : 0;
// Comparison
const mA = allMonths[a], mB = allMonths[b];
const cats = useMemo(() => {
if (!mA || !mB) return [];
const all = new Set([...Object.keys(mA.summary?.byCat || {}), ...Object.keys(mB.summary?.byCat || {})]);
return [...all].map(c => ({
cat: c, a: mA.summary.byCat[c] || 0, b: mB.summary.byCat[c] || 0,
delta: ((mB.summary.byCat[c]||0) - (mA.summary.byCat[c]||0)),
})).sort((x,y) => Math.abs(y.delta) - Math.abs(x.delta));
}, [a, b]);
return (
מגמות
השווה בין חודשים, חפש דפוסים, זהה שינויים.
{/* Hero — overview of the selected range */}
סך הוצאות · {months.length} חודשים
ממוצע {fmtILS(avgExp)}/חודש
חודש הכי יקר
{maxMonth?.label || '—'}
{fmtILS(maxMonth?.summary?.expenses || 0)}
חודש הכי זול
{minMonth?.label || '—'}
{fmtILS(minMonth?.summary?.expenses || 0)}
{months.length > 1 ? (
) : (
צריך לפחות 2 חודשי נתונים כדי להציג מגמה.
)}
{/* Per-category time series */}
קטגוריה לאורך זמן
בחר קטגוריה כדי לראות את הוצאותיה בחודשים
{catTotals.slice(0, 8).map(([c]) => {
const meta = D.categories[c] || {};
const active = c === activeCat;
return (
setSelectedCat(c)} style={{
padding: '5px 10px', borderRadius: 99,
background: active ? meta.color : 'var(--surface)',
color: active ? 'white' : 'var(--ink-2)',
border: active ? 'none' : '0.5px solid var(--rule)',
fontSize: 11.5, fontWeight: 500, cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 5,
}}>
{meta.icon || '·'} {c}
);
})}
{/* Bar chart of selected category over months */}
{activeCat && (
<>
{catSeries.map((p, i) => {
const h = p.value > 0 ? Math.max(2, (p.value / catMax) * 130) : 0;
const isMax = p.value === catMax && p.value > 0;
const meta = D.categories[activeCat] || {};
return (
0 ? 1 : 0,
}}>{p.value >= 1000 ? Math.round(p.value/100)/10+'k' : Math.round(p.value)}
0 ? 1 : 0.15,
cursor: 'help',
transition: 'background .2s',
}}
/>
);
})}
{catSeries.map((p, i) => (
{p.month.shortLabel}
))}
סך הכל
{fmtILS(catSeries.reduce((s,p) => s + p.value, 0))}
ממוצע חודשי
{fmtILS(catAvg)}
>
)}
{/* Month vs Month comparison */}
השוואה בין חודשים
setA(+e.target.value)} style={{ padding: '6px 10px', borderRadius: 8, background: 'var(--bg-deep)', border: '0.5px solid var(--rule)', fontSize: 12 }}>
{allMonths.map((m,i) => {m.label}{(m.txns?.length || 0) === 0 ? ' (ריק)' : ''} )}
↔
setB(+e.target.value)} style={{ padding: '6px 10px', borderRadius: 8, background: 'var(--bg-deep)', border: '0.5px solid var(--rule)', fontSize: 12 }}>
{allMonths.map((m,i) => {m.label}{(m.txns?.length || 0) === 0 ? ' (ריק)' : ''} )}
{cats.length === 0 ? (
בחר שני חודשים עם נתונים כדי להשוות.
) : (
{cats.slice(0, 10).map(({ cat, a: av, b: bv, delta }) => {
const max = Math.max(av, bv) || 1;
const c = D.categories[cat] || {};
return (
{cat}
0 ? 'var(--alert)' : 'var(--positive)' }}>
{delta > 0 ? '↑' : '↓'} {fmtILS(Math.abs(delta))}
);
})}
)}
);
}
// AI Report page
function AIPage() {
const { dataVersion } = useApp();
const [busy, setBusy] = useState(false);
const aiConfigured = D.aiStatus?.configured;
// Month selector — defaults to most recent month with transactions, falls
// back to current month. Same pattern as Dashboard / TxnsPage.
const monthsAll = D.months || [];
const initialIdx = useMemo(() => {
for (let i = monthsAll.length - 1; i >= 0; i--) {
if ((monthsAll[i].txns?.length || 0) > 0) return i;
}
return Math.max(0, monthsAll.length - 1);
}, [dataVersion]);
const [monthIdx, setMonthIdx] = useState(initialIdx);
useEffect(() => {
if (monthIdx >= monthsAll.length) setMonthIdx(Math.max(0, monthsAll.length - 1));
}, [dataVersion]);
const activeMonth = monthsAll[monthIdx];
// Per-month insights cache. Initialized from D.insights for the current
// month so the page doesn't double-fetch on first paint.
const isCurrentMonth = activeMonth && D.current
&& activeMonth.year === D.current.year
&& activeMonth.month === D.current.month;
const [byMonth, setByMonth] = useState(() => {
const seed = {};
if (D.current && D.insights) {
seed[`${D.current.year}-${D.current.month}`] = D.insights;
}
return seed;
});
// Fetch insights for the selected month on demand. Backend serves from DB
// cache when a report exists; this is a fast, non-AI call.
useEffect(() => {
if (!activeMonth) return;
const key = `${activeMonth.year}-${activeMonth.month}`;
if (byMonth[key] !== undefined) return; // cached
let cancelled = false;
(async () => {
try {
const r = await apiFetch(`/api/ai/insights?year=${activeMonth.year}&month=${activeMonth.month}`);
if (!r.ok) throw new Error('fetch failed');
const data = await r.json();
if (cancelled) return;
const insightObj = data.exists && data.content ? {
summary: data.content.summary,
forecast: data.content.forecast,
bullets: data.content.bullets || [],
model: data.model,
createdAt: data.created_at,
} : null;
setByMonth(prev => ({ ...prev, [key]: insightObj }));
} catch (e) {
if (!cancelled) setByMonth(prev => ({ ...prev, [key]: null }));
}
})();
return () => { cancelled = true; };
}, [activeMonth?.year, activeMonth?.month]);
const insights = activeMonth
? (byMonth[`${activeMonth.year}-${activeMonth.month}`] || null)
: null;
const hasReport = !!(insights && insights.summary && (insights.bullets?.length > 0 || insights.forecast));
const generate = async () => {
if (!aiConfigured) {
alert('AI לא מוגדר. הגדר ANTHROPIC_API_KEY ב-environment והפעל את השרת מחדש.');
return;
}
if (busy || !activeMonth) return;
if (hasReport && !confirm(`דוח קיים עבור ${activeMonth.label} יוחלף בדוח חדש. להמשיך?`)) return;
setBusy(true);
try {
const r = await apiFetch(`/api/ai/insights?year=${activeMonth.year}&month=${activeMonth.month}`, { method: 'POST' });
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.detail?.message || 'יצירת הדוח נכשלה');
}
const data = await r.json();
const key = `${activeMonth.year}-${activeMonth.month}`;
setByMonth(prev => ({ ...prev, [key]: {
summary: data.content.summary,
forecast: data.content.forecast,
bullets: data.content.bullets || [],
model: data.model,
createdAt: data.created_at,
}}));
// Also refresh top-level data so the dashboard's "featured insight" reflects new content
if (isCurrentMonth) {
await window.APP_DATA_REFRESH();
}
} catch (err) {
alert('שגיאה ביצירת דוח: ' + err.message);
} finally {
setBusy(false);
}
};
const updatedLabel = insights?.createdAt
? new Date(insights.createdAt).toLocaleString('he-IL', { dateStyle: 'short', timeStyle: 'short' })
: null;
const modelLabel = insights?.model || D.aiStatus?.model || 'Cai';
// Empty-state summary text when no report exists for the selected month
const placeholderSummary = aiConfigured
? `דוח Cai עוד לא נוצר עבור ${activeMonth?.label || 'חודש זה'}. לחץ "צור דוח" כדי לקבל סקירה אישית.`
: 'AI לא מוגדר. הגדר ANTHROPIC_API_KEY כדי לקבל תובנות ודוחות חודשיים.';
return (
דוח Cai
{activeMonth?.label || 'סקירה חודשית'} · {updatedLabel ? `עודכן ${updatedLabel}` : 'לא נוצר עדיין'} · {modelLabel}
{busy ? '⟳ Cai חושב…' : (hasReport ? '↻ צור דוח חדש' : '✦ צור דוח')}
{/* Month picker — pick which month to view/generate the report for */}
{monthsAll.map((m, i) => {
const active = i === monthIdx;
const isEmpty = (m.txns?.length || 0) === 0;
const key = `${m.year}-${m.month}`;
const reportEntry = byMonth[key];
const hasReportHere = !!(reportEntry && reportEntry.summary);
return (
setMonthIdx(i)}
disabled={isEmpty && !active}
title={isEmpty ? 'אין עסקאות בחודש זה' : (hasReportHere ? `${m.label} · יש דוח` : m.label)}
style={{
padding: '6px 12px', borderRadius: 8,
background: active ? 'var(--ink)' : 'var(--surface)',
color: active ? 'var(--bg)' : (isEmpty ? 'var(--ink-soft)' : 'var(--ink)'),
border: '0.5px solid var(--rule)',
fontSize: 12, fontWeight: active ? 600 : 500,
cursor: (isEmpty && !active) ? 'not-allowed' : 'pointer',
opacity: (isEmpty && !active) ? 0.4 : 1,
whiteSpace: 'nowrap',
fontFeatureSettings: '"tnum"',
position: 'relative',
}}>
{m.shortLabel} {String(m.year).slice(-2)}
{hasReportHere && !active && (
✦
)}
);
})}
תקציר
{insights?.summary || placeholderSummary}
{insights?.forecast && (
צפי: {insights.forecast}
)}
{(insights?.bullets || []).map((b, i) => {
const colors = { good: 'var(--positive)', warning: 'var(--alert)', info: 'var(--accent)' };
const labels = { good: 'חיובי', warning: 'התראה', info: 'מידע' };
return (
{b.kind === 'warning' ? '!' : b.kind === 'good' ? '✓' : 'i'}
{b.title}
{labels[b.kind]}
{b.text}
);
})}
);
}
// Budgets, Subs, Goals, Settings — compact
function BudgetsPage() {
const [editing, setEditing] = useState(false);
const budgetEntries = Object.entries(D.budgets || {}).filter(([, v]) => v > 0);
const isEmpty = budgetEntries.length === 0;
return (
תקציבים
setEditing(true)} style={{
background: 'var(--ink)', color: 'var(--bg)', border: 'none', padding: '8px 14px',
borderRadius: 10, fontSize: 12.5, fontWeight: 500, cursor: 'pointer',
}}>{isEmpty ? '+ הגדר תקציבים' : 'ערוך'}
{isEmpty && }
{budgetEntries.map(([cat, lim], i) => {
const spent = D.current.summary.byCat[cat] || 0;
const pct = lim > 0 ? spent/lim : 0;
const c = D.categories[cat] || {};
const over = pct > 1;
return (
{c.icon}
{cat}
{fmtILS(spent)}
/ {fmtILS(lim)}
);
})}
{editing &&
setEditing(false)} />}
);
}
function BudgetsEditModal({ onClose }) {
const cats = Object.keys(D.categories).filter(c => c !== 'הכנסה');
const [values, setValues] = useState(() => {
const obj = {};
cats.forEach(c => { obj[c] = D.budgets[c] || ''; });
return obj;
});
const [saving, setSaving] = useState(false);
const save = async () => {
setSaving(true);
try {
await Promise.all(cats.map(c => {
const amt = parseFloat(values[c]) || 0;
return apiFetch('/api/budgets', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: c, amount: amt }),
});
}));
await window.APP_DATA_REFRESH();
onClose();
} catch (err) {
alert('שמירת התקציבים נכשלה: ' + err.message);
} finally {
setSaving(false);
}
};
return (
e.stopPropagation()} className="slide-up" style={{
width: 480, maxWidth: '90%', maxHeight: '80vh', background: 'var(--surface)',
borderRadius: 16, boxShadow: 'var(--shadow-lg)', overflow: 'hidden',
display: 'flex', flexDirection: 'column',
}}>
תקציבים חודשיים
✕
השאר ריק או 0 כדי שלא יהיה תקציב לקטגוריה. הסכומים בש"ח לחודש.
{cats.map(c => {
const meta = D.categories[c] || {};
return (
{meta.icon}
{c}
setValues(v => ({ ...v, [c]: e.target.value }))}
placeholder="ללא"
style={{ width: 110, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-deep)',
border: '0.5px solid var(--rule)', fontSize: 13, outline: 'none', textAlign: 'right' }} />
₪
);
})}
ביטול
{saving ? 'שומר…' : 'שמור'}
);
}
// Per-provider metadata — UI labels, login URLs, brand colors
const PROVIDER_INFO = {
max: { label: 'Max', sub: 'מקס (לשעבר לאומי קארד)', url: 'https://www.max.co.il', color: '#0a84ff' },
visaCal: { label: 'Visa Cal', sub: 'ויזה כאל', url: 'https://www.cal-online.co.il', color: '#5856d6' },
isracard: { label: 'Isracard', sub: 'ישראכרט (כולל Isracard Visa)', url: 'https://www.isracard.co.il', color: '#ff375f' },
amex: { label: 'American Express', sub: 'אמריקן אקספרס (דרך ישראכרט)', url: 'https://www.americanexpress.co.il', color: '#1a73e8' },
};
// Scrape modal — triggered from Dashboard "סנכרון עכשיו" / per-provider Connect cards
function ScrapeModal({ onClose, defaultProvider = 'max' }) {
const [provider, setProvider] = useState(defaultProvider);
// Restore last-used username per provider (NOT password — password never persists)
const [username, setUsername] = useState(() => {
try { return localStorage.getItem(`clibank.user.${defaultProvider}`) || ''; } catch { return ''; }
});
const [password, setPassword] = useState('');
const [months, setMonths] = useState(3);
const [rememberUser, setRememberUser] = useState(true);
const [status, setStatus] = useState(null); // null | 'running' | 'done' | 'error'
const [message, setMessage] = useState('');
// When user switches provider in the dropdown, swap the saved username for it
const onProviderChange = (e) => {
const p = e.target.value;
setProvider(p);
try { setUsername(localStorage.getItem(`clibank.user.${p}`) || ''); } catch { setUsername(''); }
setPassword('');
};
const info = PROVIDER_INFO[provider] || PROVIDER_INFO.max;
const submit = async (e) => {
e.preventDefault();
if (rememberUser) {
try { localStorage.setItem(`clibank.user.${provider}`, username); } catch {}
} else {
try { localStorage.removeItem(`clibank.user.${provider}`); } catch {}
}
setStatus('running');
setMessage(`מתחבר ל-${info.label} דרך Cli-Bank… זה יכול לקחת דקה-שתיים.`);
try {
// show_browser=false because the scraper runs on Railway with no
// display server. CAPTCHA/2FA prompts (if the issuer requires them)
// would have to be handled by the scraper itself, not the user.
const r = await apiFetch('/api/scrape', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, username, password, months: +months, show_browser: false }),
});
setPassword('');
if (!r.ok) {
const err = await r.json().catch(() => ({}));
const t = err.detail?.errorType || 'UNKNOWN';
const m = err.detail?.message || 'שגיאה במשיכה';
throw new Error(`[${t}] ${m}`);
}
const data = await r.json();
setStatus('done');
setMessage(`נמשכו ${data.new_count} עסקאות חדשות מ-${info.label} (סך הכל ${data.total_seen}).`);
await window.APP_DATA_REFRESH();
} catch (err) {
setStatus('error');
setMessage(err.message || 'שגיאה');
}
};
return (
);
}
const inpStyle = { width: '100%', padding: '8px 10px', borderRadius: 9, background: 'var(--bg-deep)', border: '0.5px solid var(--rule)', fontSize: 13, outline: 'none', boxSizing: 'border-box' };
function Field({ label, children }) {
return (
);
}
function SubsPage() {
const { dataVersion } = useApp();
const [filter, setFilter] = useState('all'); // all | used | unused | high
const [subs, setSubs] = useState(D.subscriptions || []);
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(null); // currently-edited subscription object
// Refetch when data refreshes — hidden items always filtered out
useEffect(() => {
let cancelled = false;
setLoading(true);
apiFetch(`/api/subscriptions?months_back=12&min_months=3&include_hidden=false`)
.then(r => r.json())
.then(data => { if (!cancelled) setSubs(data); })
.catch(() => { if (!cancelled) setSubs(D.subscriptions || []); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [dataVersion]);
const visibleSubs = subs;
const filtered = useMemo(() => {
let arr = visibleSubs.slice();
if (filter === 'used') arr = arr.filter(s => s.used);
if (filter === 'unused') arr = arr.filter(s => !s.used);
if (filter === 'high') arr = arr.filter(s => s.amount >= 100);
return arr;
}, [filter, visibleSubs.length]);
const total = filtered.reduce((s,x) => s + x.amount, 0);
const totalUsed = visibleSubs.filter(s => s.used).reduce((s,x) => s + x.amount, 0);
const totalUnused = visibleSubs.filter(s => !s.used).reduce((s,x) => s + x.amount, 0);
// Hard delete — sub disappears from the page; data is preserved server-side
// as a hidden override so it never auto-detects again on future scrapes.
const removeSub = async (sub) => {
if (!confirm(`למחוק את "${sub.name}" מרשימת המנויים?\n\nהעסקאות עצמן לא יימחקו, אבל הספק לא יזוהה כמנוי שוב.`)) return;
try {
await apiFetch(`/api/subscriptions/${encodeURIComponent(sub.key)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden: true }),
});
// Remove from local state immediately
setSubs(prev => prev.filter(s => s.key !== sub.key));
} catch (e) {
alert('מחיקה נכשלה: ' + e.message);
}
};
return (
מנויים
זוהו אוטומטית מהיסטוריית העסקאות · 12 חודשים אחרונים · גם כל מה שסיווגת ידנית כ-"חשבונות ומנויים"
{subs.length === 0 ? (
) : (
<>
{/* Hero stats */}
סך כל החיובים
s + x.amount, 0)} size={26} weight={700} />
{subs.length} ספקים · לחודש
חיובים פעילים
{subs.filter(s => s.used).length} ב-45 ימים אחרונים
חיובים רדומים
0 ? 'var(--alert)' : 'var(--ink-soft)'} />
{subs.filter(s => !s.used).length} שלא חויבו לאחרונה
{/* Filters */}
setFilter('all')} />
s.used).length}`} on={filter==='used'} onClick={() => setFilter('used')} />
!s.used).length}`} on={filter==='unused'} onClick={() => setFilter('unused')} />
setFilter('high')} />
סינון: {fmtILS(total)}/חודש · {fmtILS(total*12)}/שנה
{filtered.map((s, i) => {
const cat = D.categories[s.category] || { color: 'var(--ink-soft)', icon: '·' };
const lastSeen = new Date(s.last_seen);
const daysAgo = Math.round((Date.now() - lastSeen.getTime()) / (1000 * 60 * 60 * 24));
const variance = s.variance_pct;
const isStrict = variance < 5; // very tight = recurring fixed bill
const isLoose = variance > 25; // wider = could be just frequent merchant
return (
{cat.icon}
{s.name}
{s.edited && ✎ }
{s.hidden && מוסתר }
{!s.hidden && isStrict && קבוע }
{!s.hidden && isLoose && משתנה }
{s.category} · {s.months} חודשים · {s.occurrences} חיובים
{' · '}
{daysAgo === 0 ? 'חויב היום' : `לפני ${daysAgo} ימים`}
{!s.used && !s.hidden && (
רדום
)}
{fmtILS(-s.amount)}
{fmtILS(-s.annual_estimate)}/שנה
{/* Action buttons */}
setEditing(s)}
title="ערוך"
style={{
padding: '6px 9px', borderRadius: 7,
background: 'transparent', color: 'var(--ink-soft)',
border: '0.5px solid var(--rule)', cursor: 'pointer',
fontSize: 12, fontWeight: 500,
}}>✎
removeSub(s)}
title="מחק מרשימת המנויים"
style={{
padding: '6px 9px', borderRadius: 7,
background: 'transparent', color: 'var(--alert)',
border: '0.5px solid rgba(216,58,58,0.3)', cursor: 'pointer',
fontSize: 12, fontWeight: 500,
}}>🗑
);
})}
{filtered.length === 0 && (
)}
איך הזיהוי עובד: {' '}
סורק את 12 החודשים האחרונים. כל ספק שמחויב ב-3+ חודשים שונים נחשב חיוב חוזר. קבוע = פחות מ-5% שינוי בסכום (כמו Netflix). משתנה = יותר מ-25% (כמו תחנת דלק שאתה מבקר בה לעיתים קרובות). רדום = לא היה חיוב ב-45 הימים האחרונים — שווה לבדוק שאין תשלום ששכחת.
>
)}
{editing &&
setEditing(null)}
onSaved={(updated) => {
setSubs(prev => prev.map(s => s.key === updated.key ? updated : s));
setEditing(null);
}}
onReset={(key) => {
// Reset = removed all overrides; refetch to get clean detection
apiFetch(`/api/subscriptions?months_back=12&min_months=3&include_hidden=false`)
.then(r => r.json())
.then(setSubs)
.catch(() => {});
setEditing(null);
}}
/>}
);
}
function SubscriptionEditModal({ sub, onClose, onSaved, onReset }) {
const [name, setName] = useState(sub.name);
const [category, setCategory] = useState(sub.category);
const [hidden, setHidden] = useState(!!sub.hidden);
const [notes, setNotes] = useState(sub.notes || '');
const [saving, setSaving] = useState(false);
const categories = Object.keys(D.categories || {});
const hasOverrides = sub.edited;
const save = async (e) => {
e?.preventDefault();
setSaving(true);
try {
// Only send changed fields; null leaves them unchanged
const body = {
display_name: name === sub.original_name ? '' : name,
category: category === sub.original_category ? '' : category,
hidden,
notes,
};
const r = await apiFetch(`/api/subscriptions/${encodeURIComponent(sub.key)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.detail || 'שמירה נכשלה');
}
onSaved({
...sub,
name,
category,
hidden,
notes,
edited: true,
});
} catch (err) {
alert('שגיאה: ' + err.message);
} finally {
setSaving(false);
}
};
const resetOverrides = async () => {
if (!confirm('לבטל את כל העריכות ולחזור לזיהוי האוטומטי המקורי?')) return;
setSaving(true);
try {
await apiFetch(`/api/subscriptions/${encodeURIComponent(sub.key)}/override`, { method: 'DELETE' });
onReset(sub.key);
} catch (err) {
alert('שגיאה: ' + err.message);
} finally {
setSaving(false);
}
};
return (
);
}
function SubFilterChip({ label, on, onClick }) {
return (
{label}
);
}
function GoalsPage() {
const goals = D.goals || [];
const [editing, setEditing] = useState(null); // null | 'new' |
const totalTarget = goals.reduce((s, g) => s + (g.target_amount || 0), 0);
const totalSaved = goals.reduce((s, g) => s + (g.saved_amount || 0), 0);
return (
יעדים
{goals.length === 0 ? 'אין יעדים פעילים — הוסף יעד חיסכון כדי לעקוב אחריו' :
`${goals.length} יעדים · ${fmtILS(totalSaved)} מתוך ${fmtILS(totalTarget)} (${Math.round((totalSaved/totalTarget)*100)}%)`}
setEditing('new')} style={{
background: 'var(--ink)', color: 'var(--bg)', border: 'none', padding: '8px 14px',
borderRadius: 10, fontSize: 12.5, fontWeight: 500, cursor: 'pointer',
}}>+ יעד חדש
{goals.length === 0 ? (
setEditing('new')} style={{
padding: '9px 18px', borderRadius: 9, background: 'var(--ink)',
color: 'var(--bg)', border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600,
}}>+ צור יעד ראשון}
/>
) : (
{goals.map(g => {
const pct = (g.target_amount > 0) ? (g.saved_amount / g.target_amount) : 0;
const cappedPct = Math.min(1, pct);
const remaining = Math.max(0, g.target_amount - g.saved_amount);
const color = g.color || 'var(--accent)';
return (
setEditing(g)} style={{ cursor: 'pointer', position: 'relative' }}>
{g.name}
{pct >= 1 &&
הושג ✓ }
{g.deadline && (
עד {fmtDate(g.deadline.slice(0, 10))}
)}
{fmtILS(g.saved_amount)}
מתוך {fmtILS(g.target_amount)} · {Math.round(pct*100)}%
{remaining > 0 && <> · נותרו {fmtILS(remaining)}>}
{g.notes && (
{g.notes}
)}
);
})}
)}
{editing &&
setEditing(null)}
onSaved={async () => {
await window.APP_DATA_REFRESH();
setEditing(null);
}}
/>}
);
}
function AccountEditModal({ account, onClose, onSaved }) {
const [name, setName] = useState(account.name || '');
const [holder, setHolder] = useState((account.holder || '').replace(/^•+\s*/, ''));
const [color, setColor] = useState(account.color || '');
const [saving, setSaving] = useState(false);
const colors = ['#0a84ff','#5856d6','#ff375f','#1a73e8','#30a46c','#f59e0b','#7c5cff','#ec4899'];
const submit = async (e) => {
e.preventDefault();
if (!name.trim()) { alert('שם נדרש'); return; }
setSaving(true);
try {
const r = await apiFetch(`/api/accounts/${account.account_id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
display_name: name.trim(),
holder_name: holder.trim() || null,
color: color || null,
}),
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.detail || 'שמירה נכשלה');
}
await onSaved();
} catch (err) {
alert('שגיאה: ' + err.message);
} finally {
setSaving(false);
}
};
return (
e.stopPropagation()} onSubmit={submit} className="slide-up" style={{
width: 440, maxWidth: '90%', background: 'var(--surface)',
borderRadius: 16, boxShadow: 'var(--shadow-lg)', overflow: 'hidden',
}}>
עריכת כרטיס
✕
ביטול
{saving ? 'שומר…' : 'שמור'}
);
}
function GoalEditModal({ goal, onClose, onSaved }) {
const isNew = !goal;
const [name, setName] = useState(goal?.name || '');
const [target, setTarget] = useState(goal?.target_amount || '');
const [saved, setSaved] = useState(goal?.saved_amount || 0);
const [deadline, setDeadline] = useState(goal?.deadline ? goal.deadline.slice(0, 10) : '');
const [color, setColor] = useState(goal?.color || '#0a84ff');
const [notes, setNotes] = useState(goal?.notes || '');
const [saving, setSaving] = useState(false);
const colors = ['#0a84ff','#5856d6','#ff375f','#30a46c','#f59e0b','#7c5cff','#ec4899','#14b8a6'];
const submit = async (e) => {
e.preventDefault();
if (!name.trim()) { alert('שם יעד נדרש'); return; }
const targetNum = parseFloat(target);
if (!targetNum || targetNum <= 0) { alert('סכום יעד חייב להיות חיובי'); return; }
setSaving(true);
try {
const body = {
name: name.trim(),
target_amount: targetNum,
saved_amount: parseFloat(saved) || 0,
deadline: deadline || null,
color,
notes: notes.trim() || null,
};
const url = isNew ? '/api/goals' : `/api/goals/${goal.id}`;
const method = isNew ? 'POST' : 'PATCH';
const r = await apiFetch(url, {
method, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.detail || 'שמירה נכשלה');
}
await onSaved();
} catch (err) {
alert('שגיאה: ' + err.message);
} finally {
setSaving(false);
}
};
const remove = async () => {
if (!confirm(`למחוק את היעד "${goal.name}"?`)) return;
setSaving(true);
try {
await apiFetch(`/api/goals/${goal.id}`, { method: 'DELETE' });
await onSaved();
} catch (err) {
alert('מחיקה נכשלה: ' + err.message);
} finally {
setSaving(false);
}
};
return (
e.stopPropagation()} onSubmit={submit} className="slide-up" style={{
width: 480, maxWidth: '90%', background: 'var(--surface)',
borderRadius: 16, boxShadow: 'var(--shadow-lg)', overflow: 'hidden',
}}>
{isNew ? 'יעד חדש' : 'עריכת יעד'}
✕
{!isNew ? (
🗑 מחק
) :
}
ביטול
{saving ? 'שומר…' : 'שמור'}
);
}
function SettingsPage() {
const { theme, setTheme, accent, setAccent, density, setDensity, setPage } = useApp();
const [scrapeOpen, setScrapeOpen] = useState(false);
const [editingAcct, setEditingAcct] = useState(null);
const archiveAccount = async (a) => {
if (!confirm(`לארכב את "${a.name}"? לא יופיע יותר ברשימה אבל הנתונים יישמרו.`)) return;
try {
const r = await apiFetch(`/api/accounts/${a.account_id}/archive`, { method: 'POST' });
if (!r.ok) throw new Error('archive failed');
await window.APP_DATA_REFRESH();
} catch (err) {
alert('שגיאה: ' + err.message);
}
};
const fmtSync = (iso) => {
if (!iso) return 'עדיין לא סונכרן';
const diff = Math.max(0, Date.now() - new Date(iso).getTime());
const m = Math.round(diff / 60000);
if (m < 1) return 'סונכרן זה עתה';
if (m < 60) return `סונכרן לפני ${m} דקות`;
const h = Math.round(m / 60);
if (h < 24) return `סונכרן לפני ${h} שעות`;
const d = Math.round(h / 24);
return d === 1 ? 'סונכרן אתמול' : `סונכרן לפני ${d} ימים`;
};
const accounts = D.accounts || [];
return (
הגדרות
כרטיסים מקושרים
{accounts.length === 0 && (
עדיין אין כרטיסים מקושרים.
)}
{accounts.map((a, i) => (
{a.name}
{a.holder} · {fmtSync(a.last_synced)}
setEditingAcct(a)} title="ערוך" style={{
background: 'transparent', border: '0.5px solid var(--rule)',
padding: '5px 9px', borderRadius: 7, fontSize: 11, cursor: 'pointer',
}}>✎
archiveAccount(a)} title="ארכב" style={{
background: 'transparent', border: '0.5px solid rgba(216,58,58,0.3)',
color: 'var(--alert)', padding: '5px 9px', borderRadius: 7, fontSize: 11, cursor: 'pointer',
}}>🗑
))}
setScrapeOpen(true)} style={{
marginTop: 12, width: '100%', padding: '10px',
background: 'var(--ink)', color: 'var(--bg)', border: 'none',
borderRadius: 9, fontSize: 13, fontWeight: 500, cursor: 'pointer',
}}>+ הוסף חשבון
{scrapeOpen && setScrapeOpen(false)} />}
{editingAcct && setEditingAcct(null)}
onSaved={async () => { await window.APP_DATA_REFRESH(); setEditingAcct(null); }}
/>}
מראה
{['#0a84ff','#5856d6','#ff375f','#34c759','#ff9500','#af52de'].map(c => (
setAccent(c)} style={{
width: 24, height: 24, borderRadius: '50%', background: c, cursor: 'pointer',
boxShadow: accent === c ? `0 0 0 2px var(--bg), 0 0 0 4px ${c}` : 'none',
}} />
))}
Cai AI
{}} />
);
}
function Row({ label, children }) {
return (
{label}
{children}
);
}
function Toggle({ on: initial }) {
const [on, setOn] = useState(initial);
return (
setOn(!on)} style={{
width: 38, height: 22, borderRadius: 99, background: on ? 'var(--accent)' : 'var(--ink-faint)',
position: 'relative', cursor: 'pointer', transition: 'background .2s',
}}>
);
}
// Transaction drawer
function TxnDrawer() {
const { drawerTxn, setDrawerTxn } = useApp();
// Local edit state — the form is only persisted when the user clicks "שמור"
const [category, setCategory] = useState(drawerTxn?.category || '');
const [note, setNote] = useState(drawerTxn?.notes || '');
const [saving, setSaving] = useState(false);
const [feedback, setFeedback] = useState(null); // {kind: 'ok'|'err', text}
// Reset local state when the drawer opens with a different transaction
useEffect(() => {
if (drawerTxn) {
setCategory(drawerTxn.category || '');
setNote(drawerTxn.notes || '');
setFeedback(null);
}
}, [drawerTxn?.id]);
if (!drawerTxn) return null;
const t = drawerTxn;
const c = D.categories[category] || D.categories[t.category] || {};
const noteOriginal = t.notes || '';
const dirty = category !== t.category || (note || '') !== noteOriginal;
const save = async () => {
if (saving) return;
if (!dirty) {
setFeedback({ kind: 'ok', text: 'אין שינויים לשמור.' });
return;
}
setSaving(true);
setFeedback(null);
try {
let related = 0;
// 1) Category change goes through merchant cascade
if (category !== t.category) {
const r = await apiFetch(`/api/transactions/${t.id}/category`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category, learn: true }),
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.detail || 'שמירת הקטגוריה נכשלה');
}
const data = await r.json().catch(() => ({}));
related = data.related_updated || 0;
}
// 2) Notes saved per-transaction via PATCH
if ((note || '') !== noteOriginal) {
const r2 = await apiFetch(`/api/transactions/${t.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes: note }),
});
if (!r2.ok) {
const err = await r2.json().catch(() => ({}));
throw new Error(err.detail || 'שמירת ההערה נכשלה');
}
}
await window.APP_DATA_REFRESH();
setDrawerTxn({ ...t, category, notes: note });
setFeedback({
kind: 'ok',
text: related > 0
? `נשמר ✓ — ועוד ${related} עסקאות עם אותו ספק עודכנו בכל החודשים.`
: 'נשמר ✓',
});
} catch (err) {
setFeedback({ kind: 'err', text: 'שגיאה: ' + (err.message || 'נכשל') });
} finally {
setSaving(false);
}
};
return (
setDrawerTxn(null)} style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)',
zIndex: 80, display: 'flex', justifyContent: 'flex-start', animation: 'fade-in .2s',
}}>
e.stopPropagation()} style={{
width: 420, height: '100%', background: 'var(--surface)',
boxShadow: 'var(--shadow-lg)', padding: 28, overflowY: 'auto',
animation: 'slide-in-end .3s cubic-bezier(.2,.8,.3,1)',
}} className="thin-scroll">
פרטי עסקה
setDrawerTxn(null)} style={{ cursor: 'pointer', color: 'var(--ink-soft)', fontSize: 16 }}>✕
{c.icon}
{t.description}
0 ? 'var(--positive)' : 'var(--ink)' }}>
{fmtILS(t.amount)}
{fmtDate(t.date)} · {t.source.toUpperCase()}
קטגוריה
setCategory(e.target.value)}
style={{ width: '100%', padding: '10px 12px', borderRadius: 10, background: 'var(--bg-deep)', border: '0.5px solid var(--rule)', fontSize: 13, outline: 'none' }}>
{Object.keys(D.categories).map(c => {c} )}
{dirty && (
✱ יש שינוי שעוד לא נשמר. לחץ "שמור" להחיל גם על עסקאות דומות בכל החודשים.
)}
הערה
setNote(e.target.value)}
placeholder="הוסף הערה אישית…"
style={{ width: '100%', padding: '10px 12px', borderRadius: 10, background: 'var(--bg-deep)', border: '0.5px solid var(--rule)', fontSize: 13, outline: 'none', minHeight: 70, resize: 'vertical', fontFamily: 'inherit' }} />
{feedback && (
{feedback.text}
)}
{saving ? 'שומר…' : (dirty ? 'שמור' : 'נשמר')}
);
}
window.TrendsPage = TrendsPage;
window.AIPage = AIPage;
window.BudgetsPage = BudgetsPage;
window.BudgetsEditModal = BudgetsEditModal;
window.SubsPage = SubsPage;
window.GoalsPage = GoalsPage;
window.SettingsPage = SettingsPage;
window.TxnDrawer = TxnDrawer;
window.ScrapeModal = ScrapeModal;