v3
This commit is contained in:
+109
-298
@@ -3,12 +3,16 @@ import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
|
||||
const emptyData = { version: 2, accounts: [], incomes: [], expenses: [], transfers: [] };
|
||||
|
||||
function formatMoney(value) {
|
||||
return euro.format(Number(value || 0));
|
||||
}
|
||||
const months = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
||||
const intervals = {
|
||||
monthly: { label: 'Monatlich', divisor: 1, step: 1 },
|
||||
quarterly: { label: 'Vierteljährlich', divisor: 3, step: 3 },
|
||||
semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 },
|
||||
yearly: { label: 'Jährlich', divisor: 12, step: 12 },
|
||||
};
|
||||
const emptyData = { version: 3, settings: { includePeriodicInBalance: true }, accounts: [], incomes: [], expenses: [], transfers: [] };
|
||||
|
||||
function formatMoney(value) { return euro.format(Number(value || 0)); }
|
||||
function parseAmount(value) {
|
||||
if (typeof value === 'number') return value;
|
||||
const raw = String(value).trim();
|
||||
@@ -17,331 +21,138 @@ function parseAmount(value) {
|
||||
const number = Number(normalized);
|
||||
return Number.isFinite(number) ? number : Number.NaN;
|
||||
}
|
||||
|
||||
function uid() {
|
||||
// crypto.randomUUID() ist in Browsern nur in sicheren Kontexten (HTTPS/localhost)
|
||||
// garantiert verfügbar. FixFin soll aber auch direkt über http://LAN-IP laufen.
|
||||
if (globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const bytes = new Uint8Array(16); globalThis.crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = [...bytes].map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
// Letzter Fallback für sehr alte Browser. Für lokale IDs reicht die Kombination.
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
function monthlyEquivalent(item) { return Number(item.amount || 0) / (intervals[item.interval]?.divisor || 1); }
|
||||
function isPeriodic(item) { return (item.interval || 'monthly') !== 'monthly'; }
|
||||
function balanceAmount(item, includePeriodic) {
|
||||
if (!isPeriodic(item)) return Number(item.amount || 0);
|
||||
return includePeriodic ? monthlyEquivalent(item) : 0;
|
||||
}
|
||||
function occursInMonth(item, monthIndex) {
|
||||
const interval = item.interval || 'monthly';
|
||||
if (interval === 'monthly') return true;
|
||||
const dueMonth = Number(item.dueMonth);
|
||||
if (!Number.isInteger(dueMonth) || dueMonth < 1 || dueMonth > 12) return false;
|
||||
const start = dueMonth - 1;
|
||||
const step = intervals[interval]?.step || 12;
|
||||
return monthIndex >= start && (monthIndex - start) % step === 0;
|
||||
}
|
||||
|
||||
function calc(data) {
|
||||
const accountStats = new Map(data.accounts.map(a => [a.id, {
|
||||
account: a,
|
||||
income: 0,
|
||||
expense: 0,
|
||||
transferIn: 0,
|
||||
transferOut: 0,
|
||||
balance: 0,
|
||||
}]));
|
||||
|
||||
const includePeriodic = data.settings?.includePeriodicInBalance !== false;
|
||||
const accountStats = new Map(data.accounts.map(a => [a.id, { account: a, income: 0, expense: 0, transferIn: 0, transferOut: 0, periodicReserve: 0, balance: 0 }]));
|
||||
for (const item of data.incomes) {
|
||||
const stat = accountStats.get(item.accountId);
|
||||
if (stat) stat.income += item.amount;
|
||||
const stat = accountStats.get(item.accountId); if (!stat) continue;
|
||||
const value = balanceAmount(item, includePeriodic); stat.income += value;
|
||||
if (isPeriodic(item)) stat.periodicReserve += monthlyEquivalent(item);
|
||||
}
|
||||
for (const item of data.expenses) {
|
||||
const stat = accountStats.get(item.accountId);
|
||||
if (stat) stat.expense += item.amount;
|
||||
const stat = accountStats.get(item.accountId); if (!stat) continue;
|
||||
const value = balanceAmount(item, includePeriodic); stat.expense += value;
|
||||
if (isPeriodic(item)) stat.periodicReserve += monthlyEquivalent(item);
|
||||
}
|
||||
for (const item of data.transfers) {
|
||||
const from = accountStats.get(item.fromAccountId);
|
||||
const to = accountStats.get(item.toAccountId);
|
||||
if (from) from.transferOut += item.amount;
|
||||
if (to) to.transferIn += item.amount;
|
||||
const value = balanceAmount(item, includePeriodic);
|
||||
const from = accountStats.get(item.fromAccountId); const to = accountStats.get(item.toAccountId);
|
||||
if (from) from.transferOut += value; if (to) to.transferIn += value;
|
||||
}
|
||||
for (const stat of accountStats.values()) stat.balance = stat.income - stat.expense + stat.transferIn - stat.transferOut;
|
||||
|
||||
for (const stat of accountStats.values()) {
|
||||
stat.balance = stat.income - stat.expense + stat.transferIn - stat.transferOut;
|
||||
}
|
||||
const income = data.incomes.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0);
|
||||
const expense = data.expenses.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0);
|
||||
const transferVolume = data.transfers.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0);
|
||||
const periodicExpenseReserve = data.expenses.filter(isPeriodic).reduce((s,x) => s + monthlyEquivalent(x), 0);
|
||||
const periodicIncomeAverage = data.incomes.filter(isPeriodic).reduce((s,x) => s + monthlyEquivalent(x), 0);
|
||||
return { accounts: [...accountStats.values()], income, expense, transferVolume, periodicExpenseReserve, periodicIncomeAverage, balance: income - expense, includePeriodic };
|
||||
}
|
||||
|
||||
const income = data.incomes.reduce((s, x) => s + x.amount, 0);
|
||||
const expense = data.expenses.reduce((s, x) => s + x.amount, 0);
|
||||
const transferVolume = data.transfers.reduce((s, x) => s + x.amount, 0);
|
||||
const balance = income - expense;
|
||||
|
||||
return {
|
||||
accounts: [...accountStats.values()],
|
||||
income,
|
||||
expense,
|
||||
transferVolume,
|
||||
balance,
|
||||
};
|
||||
function annualForecast(data) {
|
||||
const rows = months.map((name, index) => ({ name, index, income: 0, expense: 0, transferIn: 0, transferOut: 0, balance: 0, items: [] }));
|
||||
for (const item of data.incomes) for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].income += item.amount; rows[m].items.push({ ...item, kind:'income' }); }
|
||||
for (const item of data.expenses) for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].expense += item.amount; rows[m].items.push({ ...item, kind:'expense' }); }
|
||||
for (const item of data.transfers) for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].transferIn += item.amount; rows[m].transferOut += item.amount; rows[m].items.push({ ...item, kind:'transfer' }); }
|
||||
rows.forEach(r => { r.balance = r.income - r.expense; });
|
||||
const unassigned = [
|
||||
...data.incomes.map(x => ({...x, kind:'income'})),
|
||||
...data.expenses.map(x => ({...x, kind:'expense'})),
|
||||
...data.transfers.map(x => ({...x, kind:'transfer'})),
|
||||
].filter(x => isPeriodic(x) && !(Number.isInteger(Number(x.dueMonth)) && Number(x.dueMonth) >= 1 && Number(x.dueMonth) <= 12));
|
||||
return { rows, unassigned, yearIncome: rows.reduce((s,r)=>s+r.income,0), yearExpense: rows.reduce((s,r)=>s+r.expense,0) };
|
||||
}
|
||||
|
||||
function Icon({ name }) {
|
||||
const paths = {
|
||||
dashboard: <><rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/></>,
|
||||
wallet: <><path d="M4 7h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12"/><path d="M16 12h4"/></>,
|
||||
plus: <><path d="M12 5v14M5 12h14"/></>,
|
||||
minus: <><path d="M5 12h14"/></>,
|
||||
transfer: <><path d="m17 3 4 4-4 4"/><path d="M3 7h18"/><path d="m7 21-4-4 4-4"/><path d="M21 17H3"/></>,
|
||||
data: <><path d="M12 3c5 0 9 1.3 9 3s-4 3-9 3-9-1.3-9-3 4-3 9-3Z"/><path d="M3 6v6c0 1.7 4 3 9 3s9-1.3 9-3V6"/><path d="M3 12v6c0 1.7 4 3 9 3s9-1.3 9-3v-6"/></>,
|
||||
dashboard:<><rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/></>,
|
||||
wallet:<><path d="M4 7h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12"/><path d="M16 12h4"/></>,
|
||||
plus:<><path d="M12 5v14M5 12h14"/></>, minus:<><path d="M5 12h14"/></>,
|
||||
transfer:<><path d="m17 3 4 4-4 4"/><path d="M3 7h18"/><path d="m7 21-4-4 4-4"/><path d="M21 17H3"/></>,
|
||||
calendar:<><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 10h18"/></>,
|
||||
data:<><path d="M12 3c5 0 9 1.3 9 3s-4 3-9 3-9-1.3-9-3 4-3 9-3Z"/><path d="M3 6v6c0 1.7 4 3 9 3s9-1.3 9-3V6"/><path d="M3 12v6c0 1.7 4 3 9 3s9-1.3 9-3v-6"/></>,
|
||||
};
|
||||
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [data, setData] = useState(emptyData);
|
||||
const [tab, setTab] = useState('dashboard');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [dialog, setDialog] = useState(null);
|
||||
const [data,setData]=useState(emptyData), [tab,setTab]=useState('dashboard'), [loading,setLoading]=useState(true), [saving,setSaving]=useState(false), [error,setError]=useState(''), [dialog,setDialog]=useState(null);
|
||||
const totals=useMemo(()=>calc(data),[data]); const forecast=useMemo(()=>annualForecast(data),[data]);
|
||||
useEffect(()=>{ fetch('/api/data').then(async r=>{if(!r.ok)throw new Error((await r.json()).error||'Daten konnten nicht geladen werden.');return r.json();}).then(setData).catch(e=>setError(e.message)).finally(()=>setLoading(false)); },[]);
|
||||
async function persist(next){setSaving(true);setError('');try{const r=await fetch('/api/data',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(next)});const result=await r.json();if(!r.ok)throw new Error(result.error||'Speichern fehlgeschlagen.');setData(result);}catch(e){setError(e.message);throw e;}finally{setSaving(false);}}
|
||||
function removeAccount(id){const used=data.incomes.some(x=>x.accountId===id)||data.expenses.some(x=>x.accountId===id)||data.transfers.some(x=>x.fromAccountId===id||x.toAccountId===id);if(used)return setError('Das Konto wird noch verwendet. Einträge oder Transfers bitte zuerst löschen.');if(confirm('Konto wirklich löschen?'))persist({...data,accounts:data.accounts.filter(x=>x.id!==id)});}
|
||||
function removeItem(type,id){if(confirm('Eintrag wirklich löschen?'))persist({...data,[type]:data[type].filter(x=>x.id!==id)});}
|
||||
async function importFile(file){try{await persist(JSON.parse(await file.text()));setTab('dashboard');}catch(e){setError(`Import fehlgeschlagen: ${e.message}`);}}
|
||||
async function togglePeriodic(){await persist({...data,settings:{...data.settings,includePeriodicInBalance:!(data.settings?.includePeriodicInBalance!==false)}});}
|
||||
if(loading)return <div className="loading">FixFin wird geladen …</div>;
|
||||
|
||||
const totals = useMemo(() => calc(data), [data]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/data')
|
||||
.then(async r => {
|
||||
if (!r.ok) throw new Error((await r.json()).error || 'Daten konnten nicht geladen werden.');
|
||||
return r.json();
|
||||
})
|
||||
.then(setData)
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function persist(next) {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const r = await fetch('/api/data', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
const result = await r.json();
|
||||
if (!r.ok) throw new Error(result.error || 'Speichern fehlgeschlagen.');
|
||||
setData(result);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
throw e;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function removeAccount(id) {
|
||||
const used = data.incomes.some(x => x.accountId === id) ||
|
||||
data.expenses.some(x => x.accountId === id) ||
|
||||
data.transfers.some(x => x.fromAccountId === id || x.toAccountId === id);
|
||||
if (used) {
|
||||
setError('Das Konto wird noch von Einträgen oder Transfers verwendet. Diese bitte zuerst löschen.');
|
||||
return;
|
||||
}
|
||||
if (confirm('Konto wirklich löschen?')) persist({ ...data, accounts: data.accounts.filter(x => x.id !== id) });
|
||||
}
|
||||
|
||||
function removeItem(type, id) {
|
||||
if (!confirm('Eintrag wirklich löschen?')) return;
|
||||
persist({ ...data, [type]: data[type].filter(x => x.id !== id) });
|
||||
}
|
||||
|
||||
async function importFile(file) {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text);
|
||||
await persist(parsed);
|
||||
setTab('dashboard');
|
||||
} catch (e) {
|
||||
setError(`Import fehlgeschlagen: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="loading">FixFin wird geladen …</div>;
|
||||
|
||||
const nav = [
|
||||
['dashboard', 'dashboard', 'Übersicht'],
|
||||
['accounts', 'wallet', 'Konten'],
|
||||
['incomes', 'plus', 'Eingänge'],
|
||||
['expenses', 'minus', 'Ausgänge'],
|
||||
['transfers', 'transfer', 'Transfers'],
|
||||
['data', 'data', 'Daten'],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">F</div>
|
||||
<div><strong>FixFin</strong><span>Monatsplanung</span></div>
|
||||
</div>
|
||||
<nav>
|
||||
{nav.map(([id, icon, label]) => (
|
||||
<button key={id} className={tab === id ? 'active' : ''} onClick={() => setTab(id)}>
|
||||
<Icon name={icon}/><span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="save-state">{saving ? 'Speichert …' : 'Automatisch gespeichert'}</div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<h1>{nav.find(x => x[0] === tab)?.[2]}</h1>
|
||||
<p>Fixe monatliche Planung ohne Buchungsverlauf.</p>
|
||||
</div>
|
||||
{tab !== 'dashboard' && tab !== 'data' && (
|
||||
<button className="primary" onClick={() => setDialog({ type: tab })}>+ Neu</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{error && <div className="alert"><span>{error}</span><button onClick={() => setError('')}>×</button></div>}
|
||||
|
||||
{tab === 'dashboard' && <Dashboard totals={totals} />}
|
||||
{tab === 'accounts' && <Accounts data={data} onEdit={x => setDialog({ type: 'accounts', item: x })} onDelete={removeAccount} onAdd={() => setDialog({ type: 'accounts' })}/>}
|
||||
{tab === 'incomes' && <Entries title="Fixe Eingänge" type="incomes" items={data.incomes} accounts={data.accounts} onEdit={x => setDialog({ type: 'incomes', item: x })} onDelete={removeItem}/>}
|
||||
{tab === 'expenses' && <Entries title="Fixe Ausgänge" type="expenses" items={data.expenses} accounts={data.accounts} onEdit={x => setDialog({ type: 'expenses', item: x })} onDelete={removeItem}/>}
|
||||
{tab === 'transfers' && <Transfers data={data} onEdit={x => setDialog({ type: 'transfers', item: x })} onDelete={removeItem}/>}
|
||||
{tab === 'data' && <DataPage data={data} importFile={importFile}/>}
|
||||
</main>
|
||||
|
||||
<nav className="mobile-nav">
|
||||
{nav.map(([id, icon, label]) => (
|
||||
<button key={id} className={tab === id ? 'active' : ''} onClick={() => setTab(id)}>
|
||||
<Icon name={icon}/><span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{dialog && <EditorDialog dialog={dialog} data={data} onClose={() => setDialog(null)} onSave={async next => { await persist(next); setDialog(null); }}/>}
|
||||
</div>
|
||||
);
|
||||
const nav=[['dashboard','dashboard','Übersicht'],['accounts','wallet','Konten'],['incomes','plus','Eingänge'],['expenses','minus','Ausgänge'],['transfers','transfer','Transfers'],['year','calendar','Jahr'],['data','data','Daten']];
|
||||
return <div className="app-shell">
|
||||
<aside className="sidebar"><div className="brand"><div className="brand-mark">F</div><div><strong>FixFin</strong><span>Fixkostenplanung</span></div></div><nav>{nav.map(([id,icon,label])=><button key={id} className={tab===id?'active':''} onClick={()=>setTab(id)}><Icon name={icon}/><span>{label}</span></button>)}</nav><div className="save-state">{saving?'Speichert …':'Automatisch gespeichert'}</div></aside>
|
||||
<main><header className="topbar"><div><h1>{nav.find(x=>x[0]===tab)?.[2]}</h1><p>Fixe Einnahmen, Ausgaben und Transfers ohne Buchungsverlauf.</p></div>{!['dashboard','year','data'].includes(tab)&&<button className="primary" onClick={()=>setDialog({type:tab})}>+ Neu</button>}</header>
|
||||
{error&&<div className="alert"><span>{error}</span><button onClick={()=>setError('')}>×</button></div>}
|
||||
{tab==='dashboard'&&<Dashboard totals={totals} onToggle={togglePeriodic}/>} {tab==='accounts'&&<Accounts data={data} onEdit={x=>setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>} {tab==='incomes'&&<Entries type="incomes" items={data.incomes} accounts={data.accounts} onEdit={x=>setDialog({type:'incomes',item:x})} onDelete={removeItem}/>} {tab==='expenses'&&<Entries type="expenses" items={data.expenses} accounts={data.accounts} onEdit={x=>setDialog({type:'expenses',item:x})} onDelete={removeItem}/>} {tab==='transfers'&&<Transfers data={data} onEdit={x=>setDialog({type:'transfers',item:x})} onDelete={removeItem}/>} {tab==='year'&&<YearPage forecast={forecast}/>} {tab==='data'&&<DataPage data={data} importFile={importFile}/>}
|
||||
</main>
|
||||
<nav className="mobile-nav">{nav.map(([id,icon,label])=><button key={id} className={tab===id?'active':''} onClick={()=>setTab(id)}><Icon name={icon}/><span>{label}</span></button>)}</nav>
|
||||
{dialog&&<EditorDialog dialog={dialog} data={data} onClose={()=>setDialog(null)} onSave={async next=>{await persist(next);setDialog(null);}}/>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Dashboard({ totals }) {
|
||||
function Dashboard({totals,onToggle}){
|
||||
return <div className="content-stack">
|
||||
<section className="summary-grid">
|
||||
<Metric label="Fixe Eingänge / Monat" value={totals.income} positive />
|
||||
<Metric label="Fixe Ausgänge / Monat" value={totals.expense} negative />
|
||||
<Metric label="Transfers / Monat" value={totals.transferVolume} />
|
||||
<Metric label="Monatlicher Gesamtsaldo" value={totals.balance} positive={totals.balance >= 0} negative={totals.balance < 0} featured />
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="section-head"><div><h2>Saldo je Konto</h2><p>Nur fixe Monatswerte. Es gibt keinen Kontostand und keinen Buchungsverlauf.</p></div></div>
|
||||
{totals.accounts.length === 0 ? <Empty text="Lege zuerst ein Konto an."/> : (
|
||||
<div className="account-summary-grid">
|
||||
{totals.accounts.map(s => <article className="account-summary" key={s.account.id}>
|
||||
<div className="account-summary-top"><h3>{s.account.name}</h3><span className={s.balance >= 0 ? 'pill good' : 'pill bad'}>{s.balance >= 0 ? '+' : ''}{formatMoney(s.balance)}</span></div>
|
||||
<div className={`big-money ${s.balance >= 0 ? 'good-text' : 'bad-text'}`}>{formatMoney(s.balance)}</div>
|
||||
<div className="muted">monatlich nach allen Fixbewegungen</div>
|
||||
<div className="mini-grid">
|
||||
<div><span>Eingänge</span><strong className="good-text">+{formatMoney(s.income)}</strong></div>
|
||||
<div><span>Ausgänge</span><strong className="bad-text">−{formatMoney(s.expense)}</strong></div>
|
||||
<div><span>Transfers rein</span><strong className="good-text">+{formatMoney(s.transferIn)}</strong></div>
|
||||
<div><span>Transfers raus</span><strong className="bad-text">−{formatMoney(s.transferOut)}</strong></div>
|
||||
</div>
|
||||
</article>)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel total-panel">
|
||||
<div><span className="eyebrow">GESAMT</span><h2>Monatlich nach Fixkosten übrig</h2><p>Interne Transfers verändern den Gesamtsaldo nicht.</p></div>
|
||||
<div className={`grand-total ${totals.balance >= 0 ? 'good-text' : 'bad-text'}`}>{formatMoney(totals.balance)}</div>
|
||||
</section>
|
||||
</div>
|
||||
<section className="toggle-panel"><div><strong>Periodische Kosten in Monats-Saldo einrechnen</strong><span>Jährliche, halbjährliche und vierteljährliche Beträge werden auf einen Monatswert heruntergerechnet.</span></div><button className={`switch ${totals.includePeriodic?'on':''}`} onClick={onToggle} role="switch" aria-checked={totals.includePeriodic}><span/></button></section>
|
||||
<section className="summary-grid"><Metric label="Eingänge / Monat" value={totals.income} positive/><Metric label="Ausgänge / Monat" value={totals.expense} negative/><Metric label="Periodische Rücklage / Monat" value={totals.periodicExpenseReserve}/><Metric label="Monatlicher Gesamtsaldo" value={totals.balance} positive={totals.balance>=0} negative={totals.balance<0} featured/></section>
|
||||
{!totals.includePeriodic&&totals.periodicExpenseReserve>0&&<div className="info-note">Periodische Ausgaben von rechnerisch <strong>{formatMoney(totals.periodicExpenseReserve)} / Monat</strong> sind derzeit nicht im Saldo enthalten.</div>}
|
||||
<section className="panel"><div className="section-head"><div><h2>Saldo je Konto</h2><p>Monatliche Fixwerte plus optional heruntergerechnete periodische Werte.</p></div></div>{totals.accounts.length===0?<Empty text="Lege zuerst ein Konto an."/>:<div className="account-summary-grid">{totals.accounts.map(s=><article className="account-summary" key={s.account.id}><div className="account-summary-top"><h3>{s.account.name}</h3><span className={s.balance>=0?'pill good':'pill bad'}>{s.balance>=0?'+':''}{formatMoney(s.balance)}</span></div><div className={`big-money ${s.balance>=0?'good-text':'bad-text'}`}>{formatMoney(s.balance)}</div><div className="muted">monatlicher Fix-Saldo</div><div className="mini-grid"><div><span>Eingänge</span><strong className="good-text">+{formatMoney(s.income)}</strong></div><div><span>Ausgänge</span><strong className="bad-text">−{formatMoney(s.expense)}</strong></div><div><span>Transfers rein</span><strong className="good-text">+{formatMoney(s.transferIn)}</strong></div><div><span>Transfers raus</span><strong className="bad-text">−{formatMoney(s.transferOut)}</strong></div></div></article>)}</div>}</section>
|
||||
<section className="panel total-panel"><div><span className="eyebrow">GESAMT</span><h2>Monatlich nach Fixkosten übrig</h2><p>Interne Transfers verändern den Gesamtsaldo nicht.</p></div><div className={`grand-total ${totals.balance>=0?'good-text':'bad-text'}`}>{formatMoney(totals.balance)}</div></section>
|
||||
</div>;
|
||||
}
|
||||
function Metric({label,value,positive,negative,featured}){return <div className={`metric ${featured?'featured':''}`}><span>{label}</span><strong className={positive?'good-text':negative?'bad-text':''}>{formatMoney(value)}</strong></div>}
|
||||
function Accounts({data,onEdit,onDelete,onAdd}){return <section className="panel"><div className="section-head"><div><h2>Konten</h2><p>Konten dienen nur zur Zuordnung. Es gibt keine Kontostände.</p></div><button className="secondary desktop-only" onClick={onAdd}>+ Konto</button></div>{data.accounts.length===0?<Empty text="Noch keine Konten vorhanden."/>:<div className="table-wrap"><table><thead><tr><th>Konto</th><th></th></tr></thead><tbody>{data.accounts.map(a=><tr key={a.id}><td><strong>{a.name}</strong></td><td className="actions"><button onClick={()=>onEdit(a)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(a.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>}
|
||||
function Entries({type,items,accounts,onEdit,onDelete}){const accountName=id=>accounts.find(a=>a.id===id)?.name||'Unbekannt';const isIncome=type==='incomes';return <section className="panel"><div className="section-head"><div><h2>{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}</h2><p>Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.</p></div></div>{items.length===0?<Empty text={`Noch keine ${isIncome?'Eingänge':'Ausgänge'} vorhanden.`}/>:<div className="table-wrap"><table><thead><tr><th>Bezeichnung</th><th>Konto</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{items.map(x=><tr key={x.id}><td><strong>{x.name}</strong></td><td>{accountName(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'–'}</td><td className={`number ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':'−'}{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>}
|
||||
function Transfers({data,onEdit,onDelete}){const accountName=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt';return <section className="panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Verschiebungen zwischen eigenen Konten. Global saldoneutral.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<div className="table-wrap"><table><thead><tr><th>Bezeichnung</th><th>Von</th><th>Nach</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=><tr key={x.id}><td><strong>{x.name}</strong></td><td>{accountName(x.fromAccountId)}</td><td>{accountName(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'–'}</td><td className="number">{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>}
|
||||
|
||||
function Metric({ label, value, positive, negative, featured }) {
|
||||
return <div className={`metric ${featured ? 'featured' : ''}`}><span>{label}</span><strong className={positive ? 'good-text' : negative ? 'bad-text' : ''}>{formatMoney(value)}</strong></div>
|
||||
function YearPage({forecast}){
|
||||
return <div className="content-stack"><section className="summary-grid year-summary"><Metric label="Einnahmen / Jahr" value={forecast.yearIncome} positive/><Metric label="Ausgaben / Jahr" value={forecast.yearExpense} negative/><Metric label="Saldo / Jahr" value={forecast.yearIncome-forecast.yearExpense} positive={forecast.yearIncome>=forecast.yearExpense} negative={forecast.yearIncome<forecast.yearExpense}/><Metric label="Nicht zugeordnet" value={forecast.unassigned.length}/></section>
|
||||
<section className="panel"><div className="section-head"><div><h2>Jahresprognose</h2><p>Tatsächliche Fälligkeiten nach Monat. Transfers sind im Gesamtsaldo neutral.</p></div></div><div className="table-wrap"><table className="year-table"><thead><tr><th>Monat</th><th className="number">Eingänge</th><th className="number">Ausgänge</th><th className="number">Saldo</th><th>Fällige Positionen</th></tr></thead><tbody>{forecast.rows.map(r=><tr key={r.name}><td><strong>{r.name}</strong></td><td className="number good-text">+{formatMoney(r.income)}</td><td className="number bad-text">−{formatMoney(r.expense)}</td><td className={`number ${r.balance>=0?'good-text':'bad-text'}`}>{formatMoney(r.balance)}</td><td><div className="due-items">{r.items.length===0?<span className="muted">Keine</span>:r.items.map((x,i)=><span key={`${x.id}-${i}`} className={`due-chip ${x.kind}`}>{x.name} · {formatMoney(x.amount)}</span>)}</div></td></tr>)}</tbody></table></div></section>
|
||||
{forecast.unassigned.length>0&&<section className="panel warning-panel"><div className="section-head"><div><h2>Periodische Positionen ohne Monat</h2><p>Diese Werte können in den Monats-Saldo eingerechnet werden, sind aber keiner konkreten Jahresprognose zugeordnet.</p></div></div><div className="due-items">{forecast.unassigned.map(x=><span className={`due-chip ${x.kind}`} key={`${x.kind}-${x.id}`}>{x.name} · {intervals[x.interval].label} · {formatMoney(x.amount)}</span>)}</div></section>}
|
||||
</div>;
|
||||
}
|
||||
function DataPage({data,importFile}){return <div className="content-stack"><section className="panel"><div className="section-head"><div><h2>JSON sichern</h2><p>Alle Daten liegen in einer einzigen JSON-Datei. Vor jedem Speichern wird serverseitig eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row"><a className="primary button-link" href="/api/export">JSON herunterladen</a><label className="secondary button-link">JSON importieren<input type="file" accept="application/json,.json" hidden onChange={e=>e.target.files?.[0]&&importFile(e.target.files[0])}/></label></div></section><section className="panel"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>{data.accounts.length} Konten · {data.incomes.length} Eingänge · {data.expenses.length} Ausgänge · {data.transfers.length} Transfers</p></div></div><pre className="json-preview">{JSON.stringify(data,null,2)}</pre></section></div>}
|
||||
function Empty({text}){return <div className="empty">{text}</div>}
|
||||
|
||||
function Accounts({ data, onEdit, onDelete, onAdd }) {
|
||||
return <section className="panel">
|
||||
<div className="section-head"><div><h2>Konten</h2><p>Konten dienen nur zur Zuordnung deiner monatlichen Fixbewegungen. Es wird kein Kontostand gespeichert.</p></div><button className="secondary desktop-only" onClick={onAdd}>+ Konto</button></div>
|
||||
{data.accounts.length === 0 ? <Empty text="Noch keine Konten vorhanden."/> : <div className="table-wrap"><table><thead><tr><th>Konto</th><th></th></tr></thead><tbody>{data.accounts.map(a => <tr key={a.id}><td><strong>{a.name}</strong></td><td className="actions"><button onClick={() => onEdit(a)}>Bearbeiten</button><button className="danger" onClick={() => onDelete(a.id)}>Löschen</button></td></tr>)}</tbody></table></div>}
|
||||
</section>
|
||||
function EditorDialog({dialog,data,onClose,onSave}){
|
||||
const type=dialog.type,item=dialog.item; const [name,setName]=useState(item?.name||''),[amount,setAmount]=useState(item?.amount??''),[accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''),[fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''),[toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''),[interval,setInterval]=useState(item?.interval||'monthly'),[dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''),[localError,setLocalError]=useState('');
|
||||
const labels={accounts:'Konto',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'};
|
||||
async function submit(e){e.preventDefault();setLocalError('');const numeric=parseAmount(amount);if(!name.trim())return setLocalError('Bitte eine Bezeichnung eingeben.');if(type!=='accounts'&&(numeric<0||!Number.isFinite(numeric)))return setLocalError('Bitte einen gültigen Betrag eingeben.');if(type!=='accounts'&&data.accounts.length===0)return setLocalError('Bitte zuerst ein Konto anlegen.');if(type==='transfers'&&fromAccountId===toAccountId)return setLocalError('Quell- und Zielkonto müssen verschieden sein.');let next=structuredClone(data);const id=item?.id||uid();let value;if(type==='accounts')value={id,name:name.trim()};else {const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null)};if(type==='incomes'||type==='expenses')value={...common,accountId};if(type==='transfers')value={...common,fromAccountId,toAccountId};}next[type]=item?next[type].map(x=>x.id===item.id?value:x):[...next[type],value];try{await onSave(next);}catch(e){setLocalError(e.message);}}
|
||||
return <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}><div className="modal-head"><div><span className="eyebrow">{item?'BEARBEITEN':'NEU'}</span><h2>{labels[type]}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>{localError&&<div className="alert">{localError}</div>}<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder={type==='accounts'?'z. B. Girokonto':'z. B. Kfz-Versicherung'}/></label>
|
||||
{type!=='accounts'&&<><label><span>Betrag pro Intervall</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span>€</span></div></label><label><span>Intervall</span><select value={interval} onChange={e=>setInterval(e.target.value)}>{Object.entries(intervals).map(([key,x])=><option key={key} value={key}>{x.label}</option>)}</select></label>{interval!=='monthly'&&<label><span>{interval==='yearly'?'Monat der Fälligkeit (optional)':'Erster Fälligkeitsmonat (optional)'}</span><select value={dueMonth} onChange={e=>setDueMonth(e.target.value)}><option value="">Nicht festgelegt</option>{months.map((m,i)=><option key={m} value={i+1}>{m}</option>)}</select><small className="field-help">{interval==='quarterly'?'Ab diesem Monat alle 3 Monate.':interval==='semiannual'?'Ab diesem Monat alle 6 Monate.':'Für die Jahresprognose.'}</small></label>}</>}
|
||||
{(type==='incomes'||type==='expenses')&&<label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>}{type==='transfers'&&<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button type="submit" className="primary">Speichern</button></div></form></div>;
|
||||
}
|
||||
|
||||
function Entries({ type, items, accounts, onEdit, onDelete }) {
|
||||
const accountName = id => accounts.find(a => a.id === id)?.name || 'Unbekannt';
|
||||
const isIncome = type === 'incomes';
|
||||
return <section className="panel">
|
||||
<div className="section-head"><div><h2>{isIncome ? 'Fixe Eingänge' : 'Fixe Ausgänge'}</h2><p>Alle Beträge gelten monatlich und haben bewusst kein Datum.</p></div></div>
|
||||
{items.length === 0 ? <Empty text={`Noch keine ${isIncome ? 'Eingänge' : 'Ausgänge'} vorhanden.`}/> : <div className="table-wrap"><table><thead><tr><th>Bezeichnung</th><th>Konto</th><th className="number">Monatlich</th><th></th></tr></thead><tbody>{items.map(x => <tr key={x.id}><td><strong>{x.name}</strong></td><td>{accountName(x.accountId)}</td><td className={`number ${isIncome ? 'good-text' : 'bad-text'}`}>{isIncome ? '+' : '−'}{formatMoney(x.amount)}</td><td className="actions"><button onClick={() => onEdit(x)}>Bearbeiten</button><button className="danger" onClick={() => onDelete(type, x.id)}>Löschen</button></td></tr>)}</tbody></table></div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function Transfers({ data, onEdit, onDelete }) {
|
||||
const accountName = id => data.accounts.find(a => a.id === id)?.name || 'Unbekannt';
|
||||
return <section className="panel">
|
||||
<div className="section-head"><div><h2>Fixe Transfers</h2><p>Verschiebungen zwischen eigenen Konten. Global saldoneutral.</p></div></div>
|
||||
{data.transfers.length === 0 ? <Empty text="Noch keine Transfers vorhanden."/> : <div className="table-wrap"><table><thead><tr><th>Bezeichnung</th><th>Von</th><th>Nach</th><th className="number">Monatlich</th><th></th></tr></thead><tbody>{data.transfers.map(x => <tr key={x.id}><td><strong>{x.name}</strong></td><td>{accountName(x.fromAccountId)}</td><td>{accountName(x.toAccountId)}</td><td className="number">{formatMoney(x.amount)}</td><td className="actions"><button onClick={() => onEdit(x)}>Bearbeiten</button><button className="danger" onClick={() => onDelete('transfers', x.id)}>Löschen</button></td></tr>)}</tbody></table></div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function DataPage({ data, importFile }) {
|
||||
return <div className="content-stack">
|
||||
<section className="panel">
|
||||
<div className="section-head"><div><h2>JSON sichern</h2><p>Alle Daten liegen in einer einzigen JSON-Datei. Vor jedem Speichern wird serverseitig zusätzlich eine <code>.bak</code>-Datei angelegt.</p></div></div>
|
||||
<div className="button-row"><a className="primary button-link" href="/api/export">JSON herunterladen</a><label className="secondary button-link">JSON importieren<input type="file" accept="application/json,.json" hidden onChange={e => e.target.files?.[0] && importFile(e.target.files[0])}/></label></div>
|
||||
</section>
|
||||
<section className="panel"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>{data.accounts.length} Konten · {data.incomes.length} Eingänge · {data.expenses.length} Ausgänge · {data.transfers.length} Transfers</p></div></div><pre className="json-preview">{JSON.stringify(data, null, 2)}</pre></section>
|
||||
</div>
|
||||
}
|
||||
|
||||
function Empty({ text }) {
|
||||
return <div className="empty">{text}</div>
|
||||
}
|
||||
|
||||
function EditorDialog({ dialog, data, onClose, onSave }) {
|
||||
const type = dialog.type;
|
||||
const item = dialog.item;
|
||||
const [name, setName] = useState(item?.name || '');
|
||||
const [amount, setAmount] = useState(item?.amount ?? '');
|
||||
const [accountId, setAccountId] = useState(item?.accountId || data.accounts[0]?.id || '');
|
||||
const [fromAccountId, setFromAccountId] = useState(item?.fromAccountId || data.accounts[0]?.id || '');
|
||||
const [toAccountId, setToAccountId] = useState(item?.toAccountId || data.accounts[1]?.id || data.accounts[0]?.id || '');
|
||||
const [localError, setLocalError] = useState('');
|
||||
|
||||
const labels = { accounts: 'Konto', incomes: 'Eingang', expenses: 'Ausgang', transfers: 'Transfer' };
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setLocalError('');
|
||||
const numeric = parseAmount(amount);
|
||||
if (!name.trim()) return setLocalError('Bitte eine Bezeichnung eingeben.');
|
||||
if (type !== 'accounts' && (numeric < 0 || !Number.isFinite(numeric))) return setLocalError('Bitte einen gültigen Betrag eingeben.');
|
||||
if (type !== 'accounts' && data.accounts.length === 0) return setLocalError('Bitte zuerst ein Konto anlegen.');
|
||||
if (type === 'transfers' && fromAccountId === toAccountId) return setLocalError('Quell- und Zielkonto müssen verschieden sein.');
|
||||
|
||||
let next = structuredClone(data);
|
||||
const id = item?.id || uid();
|
||||
let value;
|
||||
if (type === 'accounts') value = { id, name: name.trim() };
|
||||
if (type === 'incomes' || type === 'expenses') value = { id, name: name.trim(), amount: numeric, accountId };
|
||||
if (type === 'transfers') value = { id, name: name.trim(), amount: numeric, fromAccountId, toAccountId };
|
||||
next[type] = item ? next[type].map(x => x.id === item.id ? value : x) : [...next[type], value];
|
||||
try { await onSave(next); } catch (e) { setLocalError(e.message); }
|
||||
}
|
||||
|
||||
return <div className="modal-backdrop" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<form className="modal" onSubmit={submit}>
|
||||
<div className="modal-head"><div><span className="eyebrow">{item ? 'BEARBEITEN' : 'NEU'}</span><h2>{labels[type]}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>
|
||||
{localError && <div className="alert">{localError}</div>}
|
||||
<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e => setName(e.target.value)} placeholder={type === 'accounts' ? 'z. B. Girokonto' : 'z. B. Gehalt'} /></label>
|
||||
{type !== 'accounts' && <label><span>Betrag pro Monat</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e => setAmount(e.target.value)} placeholder="0,00"/><span>€</span></div></label>}
|
||||
{(type === 'incomes' || type === 'expenses') && <label><span>Konto</span><select value={accountId} onChange={e => setAccountId(e.target.value)}>{data.accounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}</select></label>}
|
||||
{type === 'transfers' && <><label><span>Von Konto</span><select value={fromAccountId} onChange={e => setFromAccountId(e.target.value)}>{data.accounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e => setToAccountId(e.target.value)}>{data.accounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}
|
||||
<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button type="submit" className="primary">Speichern</button></div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App/>);
|
||||
|
||||
@@ -126,3 +126,25 @@ code { color:#b8c3ff; }
|
||||
.summary-grid { grid-template-columns:1fr; }
|
||||
.topbar .primary { padding:9px 11px; }
|
||||
}
|
||||
|
||||
/* v1.2: periodische Fixkosten + Jahresprognose */
|
||||
.toggle-panel { display:flex; align-items:center; justify-content:space-between; gap:20px; padding:16px 18px; border:1px solid var(--line); background:#101722; border-radius:15px; }
|
||||
.toggle-panel strong { display:block; font-size:14px; }
|
||||
.toggle-panel span:not(.switch span) { display:block; color:var(--muted); font-size:12px; margin-top:4px; }
|
||||
.switch { width:48px; height:27px; border-radius:999px; border:1px solid #354257; padding:2px; background:#202937; flex:0 0 auto; transition:.2s; }
|
||||
.switch span { display:block; width:21px; height:21px; border-radius:50%; background:#8793a4; transition:.2s; }
|
||||
.switch.on { background:var(--accent); border-color:var(--accent); }
|
||||
.switch.on span { transform:translateX(20px); background:#101522; }
|
||||
.info-note { padding:12px 15px; border-radius:12px; border:1px solid #65562a; background:#2b2616; color:#e9d98b; font-size:13px; }
|
||||
.interval-badge { display:inline-block; padding:4px 8px; border-radius:999px; background:#1c2635; border:1px solid #304057; color:#b8c5d7; font-size:10px; font-weight:800; white-space:nowrap; }
|
||||
.field-help { color:var(--muted); font-size:11px; margin-top:-2px; }
|
||||
.year-summary { grid-template-columns:repeat(4,minmax(0,1fr)); }
|
||||
.year-table { min-width:850px; }
|
||||
.due-items { display:flex; flex-wrap:wrap; gap:5px; }
|
||||
.due-chip { display:inline-flex; padding:4px 7px; border-radius:7px; font-size:10px; border:1px solid #344057; background:#192231; color:#c4cede; white-space:nowrap; }
|
||||
.due-chip.income { border-color:#245947; color:#7de0b5; background:#112c24; }
|
||||
.due-chip.expense { border-color:#67333b; color:#ff9aa2; background:#321a20; }
|
||||
.due-chip.transfer { border-color:#414d7d; color:#adb9ff; background:#1c2340; }
|
||||
.warning-panel { border-color:#5a4e2b; }
|
||||
@media (max-width:760px) { .toggle-panel { align-items:flex-start; } .year-summary { grid-template-columns:1fr 1fr; } .mobile-nav { grid-template-columns:repeat(7,1fr); } }
|
||||
@media (max-width:430px) { .year-summary { grid-template-columns:1fr; } }
|
||||
|
||||
Reference in New Issue
Block a user