diff --git a/README.md b/README.md index 92a5ec7..04bdff2 100644 --- a/README.md +++ b/README.md @@ -85,3 +85,10 @@ sudo FIXFIN_IMAGE=git.example.de/owner/fixfin:latest docker compose -f docker-co ## JSON-Migration Alte FixFin-v1-Dateien mit `balance` in Konten werden automatisch auf v2 migriert. Die alten Kontostände werden entfernt; Eingänge, Ausgänge und Transfers bleiben erhalten. Vor dem Umschreiben wird `finance.json.bak` angelegt. + +## Periodische Fixkosten und Jahresprognose (v1.2) + +Eingänge, Ausgänge und Transfers unterstützen `monthly`, `quarterly`, `semiannual` und `yearly`. +Der eingegebene Betrag ist immer der Betrag pro Intervall. Für Salden kann FixFin periodische Werte automatisch auf einen Monatsdurchschnitt umrechnen. Diese Berücksichtigung lässt sich global in der Übersicht ein-/ausschalten. + +Für nicht-monatliche Positionen kann optional ein Fälligkeitsmonat angegeben werden. Bei vierteljährlichen und halbjährlichen Positionen ist dies der erste Fälligkeitsmonat. Die Jahresansicht verteilt die Position anschließend auf die tatsächlichen Fälligkeitsmonate. Positionen ohne Monat bleiben für die Monatsdurchschnittsrechnung nutzbar, werden in der Jahresprognose aber separat als nicht zugeordnet angezeigt. diff --git a/package.json b/package.json index e2d55b5..761a53d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fixfin", - "version": "1.1.1", + "version": "1.2.0", "private": true, "type": "module", "scripts": { diff --git a/server/index.js b/server/index.js index 8d63270..12b8acc 100644 --- a/server/index.js +++ b/server/index.js @@ -4,152 +4,18 @@ import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const app = express(); -const port = Number(process.env.PORT || 3000); -const dataFile = process.env.DATA_FILE || '/app/data/finance.json'; -const dataDir = path.dirname(dataFile); -const backupFile = `${dataFile}.bak`; +const __filename=fileURLToPath(import.meta.url); const __dirname=path.dirname(__filename); const app=express(); +const port=Number(process.env.PORT||3000); const dataFile=process.env.DATA_FILE||'/app/data/finance.json'; const dataDir=path.dirname(dataFile); const backupFile=`${dataFile}.bak`; +const validIntervals=new Set(['monthly','quarterly','semiannual','yearly']); +const emptyData=()=>({version:3,settings:{includePeriodicInBalance:true},accounts:[],incomes:[],expenses:[],transfers:[]}); +const isFiniteNumber=value=>typeof value==='number'&&Number.isFinite(value); -const emptyData = () => ({ - version: 2, - accounts: [], - incomes: [], - expenses: [], - transfers: [], -}); - -function isFiniteNumber(value) { - return typeof value === 'number' && Number.isFinite(value); -} - -function normalizeData(input) { - if (!input || typeof input !== 'object') return input; - return { - version: 2, - accounts: Array.isArray(input.accounts) - ? input.accounts.map(({ id, name }) => ({ id, name })) - : input.accounts, - incomes: input.incomes, - expenses: input.expenses, - transfers: input.transfers, - }; -} - -function validateData(input) { - if (!input || typeof input !== 'object') throw new Error('Ungültiges JSON.'); - for (const key of ['accounts', 'incomes', 'expenses', 'transfers']) { - if (!Array.isArray(input[key])) throw new Error(`Feld '${key}' muss ein Array sein.`); - } - - const accountIds = new Set(); - for (const account of input.accounts) { - if (!account.id || typeof account.id !== 'string') throw new Error('Konto ohne gültige ID.'); - if (!account.name || typeof account.name !== 'string') throw new Error('Konto ohne Namen.'); - if (accountIds.has(account.id)) throw new Error('Doppelte Konto-ID.'); - accountIds.add(account.id); - } - - for (const collectionName of ['incomes', 'expenses']) { - for (const item of input[collectionName]) { - if (!item.id || typeof item.id !== 'string') throw new Error('Eintrag ohne gültige ID.'); - if (!item.name || typeof item.name !== 'string') throw new Error('Eintrag ohne Namen.'); - if (!isFiniteNumber(item.amount) || item.amount < 0) throw new Error(`Ungültiger Betrag bei '${item.name}'.`); - if (!accountIds.has(item.accountId)) throw new Error(`Unbekanntes Konto bei '${item.name}'.`); - } - } - - for (const transfer of input.transfers) { - if (!transfer.id || typeof transfer.id !== 'string') throw new Error('Transfer ohne gültige ID.'); - if (!transfer.name || typeof transfer.name !== 'string') throw new Error('Transfer ohne Namen.'); - if (!isFiniteNumber(transfer.amount) || transfer.amount < 0) throw new Error(`Ungültiger Transferbetrag bei '${transfer.name}'.`); - if (!accountIds.has(transfer.fromAccountId) || !accountIds.has(transfer.toAccountId)) { - throw new Error(`Unbekanntes Konto beim Transfer '${transfer.name}'.`); - } - if (transfer.fromAccountId === transfer.toAccountId) throw new Error('Quell- und Zielkonto müssen verschieden sein.'); - } -} - -async function ensureDataFile() { - await fs.mkdir(dataDir, { recursive: true }); - try { - await fs.access(dataFile); - } catch { - await atomicWrite(emptyData(), false); - } -} - -async function readData() { - await ensureDataFile(); - const raw = await fs.readFile(dataFile, 'utf8'); - const parsed = JSON.parse(raw); - const data = normalizeData(parsed); - validateData(data); - - // Migration von v1: alte account.balance-Werte werden bewusst entfernt. - const hadLegacyBalance = Array.isArray(parsed.accounts) && parsed.accounts.some(a => Object.hasOwn(a, 'balance')); - if (parsed.version !== 2 || hadLegacyBalance) { - await atomicWrite(data, true); - } - - return data; -} - -async function atomicWrite(input, makeBackup = true) { - const data = normalizeData(input); - validateData(data); - await fs.mkdir(dataDir, { recursive: true }); - const tmp = `${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`; - const payload = JSON.stringify(data, null, 2) + '\n'; - - if (makeBackup) { - try { - await fs.copyFile(dataFile, backupFile); - } catch (error) { - if (error.code !== 'ENOENT') throw error; - } - } - - await fs.writeFile(tmp, payload, { encoding: 'utf8', mode: 0o600 }); - await fs.rename(tmp, dataFile); -} - -app.disable('x-powered-by'); -app.use(express.json({ limit: '1mb' })); - -app.get('/api/health', (_req, res) => res.json({ ok: true })); - -app.get('/api/data', async (_req, res, next) => { - try { res.json(await readData()); } catch (error) { next(error); } -}); - -app.put('/api/data', async (req, res, next) => { - try { - await atomicWrite(req.body); - res.json(await readData()); - } catch (error) { next(error); } -}); - -app.get('/api/export', async (_req, res, next) => { - try { - const data = await readData(); - res.setHeader('Content-Disposition', 'attachment; filename="fixfin-backup.json"'); - res.type('application/json').send(JSON.stringify(data, null, 2) + '\n'); - } catch (error) { next(error); } -}); - -const dist = path.resolve(__dirname, '../dist'); -app.use(express.static(dist)); -app.use((_req, res) => res.sendFile(path.join(dist, 'index.html'))); - -app.use((error, _req, res, _next) => { - console.error(error); - res.status(400).json({ error: error.message || 'Unbekannter Fehler' }); -}); - -await ensureDataFile(); -app.listen(port, '0.0.0.0', () => { - console.log(`FixFin läuft auf Port ${port}`); - console.log(`Datendatei: ${dataFile}`); -}); +function normalizeMovement(item={}) { return { ...item, interval: validIntervals.has(item.interval)?item.interval:'monthly', dueMonth: item.interval && item.interval!=='monthly' && Number.isInteger(Number(item.dueMonth)) ? Number(item.dueMonth) : null }; } +function normalizeData(input){if(!input||typeof input!=='object')return input;return {version:3,settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false},accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts,incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeMovement):input.incomes,expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeMovement):input.expenses,transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeMovement):input.transfers};} +function validateMovement(item){if(!item.id||typeof item.id!=='string')throw new Error('Eintrag ohne gültige ID.');if(!item.name||typeof item.name!=='string')throw new Error('Eintrag ohne Namen.');if(!isFiniteNumber(item.amount)||item.amount<0)throw new Error(`Ungültiger Betrag bei '${item.name}'.`);if(!validIntervals.has(item.interval))throw new Error(`Ungültiges Intervall bei '${item.name}'.`);if(item.dueMonth!==null&&item.dueMonth!==undefined&&(!Number.isInteger(item.dueMonth)||item.dueMonth<1||item.dueMonth>12))throw new Error(`Ungültiger Monat bei '${item.name}'.`);} +function validateData(input){if(!input||typeof input!=='object')throw new Error('Ungültiges JSON.');for(const key of ['accounts','incomes','expenses','transfers'])if(!Array.isArray(input[key]))throw new Error(`Feld '${key}' muss ein Array sein.`);const accountIds=new Set();for(const a of input.accounts){if(!a.id||typeof a.id!=='string')throw new Error('Konto ohne gültige ID.');if(!a.name||typeof a.name!=='string')throw new Error('Konto ohne Namen.');if(accountIds.has(a.id))throw new Error('Doppelte Konto-ID.');accountIds.add(a.id);}for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);}for(const t of input.transfers){validateMovement(t);if(!accountIds.has(t.fromAccountId)||!accountIds.has(t.toAccountId))throw new Error(`Unbekanntes Konto beim Transfer '${t.name}'.`);if(t.fromAccountId===t.toAccountId)throw new Error('Quell- und Zielkonto müssen verschieden sein.');}} +async function ensureDataFile(){await fs.mkdir(dataDir,{recursive:true});try{await fs.access(dataFile);}catch{await atomicWrite(emptyData(),false);}} +async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);if(parsed.version!==3||!parsed.settings||[...parsed.incomes||[],...parsed.expenses||[],...parsed.transfers||[]].some(x=>!x.interval)||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;} +async function atomicWrite(input,makeBackup=true){const data=normalizeData(input);validateData(data);await fs.mkdir(dataDir,{recursive:true});const tmp=`${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`;const payload=JSON.stringify(data,null,2)+'\n';if(makeBackup){try{await fs.copyFile(dataFile,backupFile);}catch(error){if(error.code!=='ENOENT')throw error;}}await fs.writeFile(tmp,payload,{encoding:'utf8',mode:0o600});await fs.rename(tmp,dataFile);} +app.disable('x-powered-by');app.use(express.json({limit:'1mb'}));app.get('/api/health',(_req,res)=>res.json({ok:true}));app.get('/api/data',async(_req,res,next)=>{try{res.json(await readData());}catch(e){next(e);}});app.put('/api/data',async(req,res,next)=>{try{await atomicWrite(req.body);res.json(await readData());}catch(e){next(e);}});app.get('/api/export',async(_req,res,next)=>{try{const data=await readData();res.setHeader('Content-Disposition','attachment; filename="fixfin-backup.json"');res.type('application/json').send(JSON.stringify(data,null,2)+'\n');}catch(e){next(e);}}); +const dist=path.resolve(__dirname,'../dist');app.use(express.static(dist));app.use((_req,res)=>res.sendFile(path.join(dist,'index.html')));app.use((error,_req,res,_next)=>{console.error(error);res.status(400).json({error:error.message||'Unbekannter Fehler'});});await ensureDataFile();app.listen(port,'0.0.0.0',()=>{console.log(`FixFin läuft auf Port ${port}`);console.log(`Datendatei: ${dataFile}`);}); diff --git a/src/main.jsx b/src/main.jsx index 8aff85b..763b28a 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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: <>, - wallet: <>, - plus: <>, - minus: <>, - transfer: <>, - data: <>, + dashboard:<>, + wallet:<>, + plus:<>, minus:<>, + transfer:<>, + calendar:<>, + data:<>, }; return ; } 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
FixFin wird geladen …
; - 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
FixFin wird geladen …
; - - const nav = [ - ['dashboard', 'dashboard', 'Übersicht'], - ['accounts', 'wallet', 'Konten'], - ['incomes', 'plus', 'Eingänge'], - ['expenses', 'minus', 'Ausgänge'], - ['transfers', 'transfer', 'Transfers'], - ['data', 'data', 'Daten'], - ]; - - return ( -
- - -
-
-
-

{nav.find(x => x[0] === tab)?.[2]}

-

Fixe monatliche Planung ohne Buchungsverlauf.

-
- {tab !== 'dashboard' && tab !== 'data' && ( - - )} -
- - {error &&
{error}
} - - {tab === 'dashboard' && } - {tab === 'accounts' && setDialog({ type: 'accounts', item: x })} onDelete={removeAccount} onAdd={() => setDialog({ type: 'accounts' })}/>} - {tab === 'incomes' && setDialog({ type: 'incomes', item: x })} onDelete={removeItem}/>} - {tab === 'expenses' && setDialog({ type: 'expenses', item: x })} onDelete={removeItem}/>} - {tab === 'transfers' && setDialog({ type: 'transfers', item: x })} onDelete={removeItem}/>} - {tab === 'data' && } -
- - - - {dialog && setDialog(null)} onSave={async next => { await persist(next); setDialog(null); }}/>} -
- ); + 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
+ +

{nav.find(x=>x[0]===tab)?.[2]}

Fixe Einnahmen, Ausgaben und Transfers ohne Buchungsverlauf.

{!['dashboard','year','data'].includes(tab)&&}
+ {error&&
{error}
} + {tab==='dashboard'&&} {tab==='accounts'&&setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>} {tab==='incomes'&&setDialog({type:'incomes',item:x})} onDelete={removeItem}/>} {tab==='expenses'&&setDialog({type:'expenses',item:x})} onDelete={removeItem}/>} {tab==='transfers'&&setDialog({type:'transfers',item:x})} onDelete={removeItem}/>} {tab==='year'&&} {tab==='data'&&} +
+ + {dialog&&setDialog(null)} onSave={async next=>{await persist(next);setDialog(null);}}/>} +
; } -function Dashboard({ totals }) { +function Dashboard({totals,onToggle}){ return
-
- - - - = 0} negative={totals.balance < 0} featured /> -
- -
-

Saldo je Konto

Nur fixe Monatswerte. Es gibt keinen Kontostand und keinen Buchungsverlauf.

- {totals.accounts.length === 0 ? : ( -
- {totals.accounts.map(s =>
-

{s.account.name}

= 0 ? 'pill good' : 'pill bad'}>{s.balance >= 0 ? '+' : ''}{formatMoney(s.balance)}
-
= 0 ? 'good-text' : 'bad-text'}`}>{formatMoney(s.balance)}
-
monatlich nach allen Fixbewegungen
-
-
Eingänge+{formatMoney(s.income)}
-
Ausgänge−{formatMoney(s.expense)}
-
Transfers rein+{formatMoney(s.transferIn)}
-
Transfers raus−{formatMoney(s.transferOut)}
-
-
)} -
- )} -
- -
-
GESAMT

Monatlich nach Fixkosten übrig

Interne Transfers verändern den Gesamtsaldo nicht.

-
= 0 ? 'good-text' : 'bad-text'}`}>{formatMoney(totals.balance)}
-
-
+
Periodische Kosten in Monats-Saldo einrechnenJährliche, halbjährliche und vierteljährliche Beträge werden auf einen Monatswert heruntergerechnet.
+
=0} negative={totals.balance<0} featured/>
+ {!totals.includePeriodic&&totals.periodicExpenseReserve>0&&
Periodische Ausgaben von rechnerisch {formatMoney(totals.periodicExpenseReserve)} / Monat sind derzeit nicht im Saldo enthalten.
} +

Saldo je Konto

Monatliche Fixwerte plus optional heruntergerechnete periodische Werte.

{totals.accounts.length===0?:
{totals.accounts.map(s=>

{s.account.name}

=0?'pill good':'pill bad'}>{s.balance>=0?'+':''}{formatMoney(s.balance)}
=0?'good-text':'bad-text'}`}>{formatMoney(s.balance)}
monatlicher Fix-Saldo
Eingänge+{formatMoney(s.income)}
Ausgänge−{formatMoney(s.expense)}
Transfers rein+{formatMoney(s.transferIn)}
Transfers raus−{formatMoney(s.transferOut)}
)}
}
+
GESAMT

Monatlich nach Fixkosten übrig

Interne Transfers verändern den Gesamtsaldo nicht.

=0?'good-text':'bad-text'}`}>{formatMoney(totals.balance)}
+ ; } +function Metric({label,value,positive,negative,featured}){return
{label}{formatMoney(value)}
} +function Accounts({data,onEdit,onDelete,onAdd}){return

Konten

Konten dienen nur zur Zuordnung. Es gibt keine Kontostände.

{data.accounts.length===0?:
{data.accounts.map(a=>)}
Konto
{a.name}
}
} +function Entries({type,items,accounts,onEdit,onDelete}){const accountName=id=>accounts.find(a=>a.id===id)?.name||'Unbekannt';const isIncome=type==='incomes';return

{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}

Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.

{items.length===0?:
{items.map(x=>)}
BezeichnungKontoIntervallMonatBetragØ / Monat
{x.name}{accountName(x.accountId)}{intervals[x.interval||'monthly'].label}{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'–'}{isIncome?'+':'−'}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
}
} +function Transfers({data,onEdit,onDelete}){const accountName=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt';return

Fixe Transfers

Verschiebungen zwischen eigenen Konten. Global saldoneutral.

{data.transfers.length===0?:
{data.transfers.map(x=>)}
BezeichnungVonNachIntervallMonatBetragØ / Monat
{x.name}{accountName(x.fromAccountId)}{accountName(x.toAccountId)}{intervals[x.interval||'monthly'].label}{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'–'}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
}
} -function Metric({ label, value, positive, negative, featured }) { - return
{label}{formatMoney(value)}
+function YearPage({forecast}){ + return
=forecast.yearExpense} negative={forecast.yearIncome
+

Jahresprognose

Tatsächliche Fälligkeiten nach Monat. Transfers sind im Gesamtsaldo neutral.

{forecast.rows.map(r=>)}
MonatEingängeAusgängeSaldoFällige Positionen
{r.name}+{formatMoney(r.income)}−{formatMoney(r.expense)}=0?'good-text':'bad-text'}`}>{formatMoney(r.balance)}
{r.items.length===0?Keine:r.items.map((x,i)=>{x.name} · {formatMoney(x.amount)})}
+ {forecast.unassigned.length>0&&

Periodische Positionen ohne Monat

Diese Werte können in den Monats-Saldo eingerechnet werden, sind aber keiner konkreten Jahresprognose zugeordnet.

{forecast.unassigned.map(x=>{x.name} · {intervals[x.interval].label} · {formatMoney(x.amount)})}
} +
; } +function DataPage({data,importFile}){return

JSON sichern

Alle Daten liegen in einer einzigen JSON-Datei. Vor jedem Speichern wird serverseitig eine .bak-Datei angelegt.

JSON herunterladen

Aktueller Datenstand

{data.accounts.length} Konten · {data.incomes.length} Eingänge · {data.expenses.length} Ausgänge · {data.transfers.length} Transfers

{JSON.stringify(data,null,2)}
} +function Empty({text}){return
{text}
} -function Accounts({ data, onEdit, onDelete, onAdd }) { - return
-

Konten

Konten dienen nur zur Zuordnung deiner monatlichen Fixbewegungen. Es wird kein Kontostand gespeichert.

- {data.accounts.length === 0 ? :
{data.accounts.map(a => )}
Konto
{a.name}
} -
+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
e.target===e.currentTarget&&onClose()}>
{item?'BEARBEITEN':'NEU'}

{labels[type]}

{localError&&
{localError}
} + {type!=='accounts'&&<>{interval!=='monthly'&&}} + {(type==='incomes'||type==='expenses')&&}{type==='transfers'&&<>}
; } - -function Entries({ type, items, accounts, onEdit, onDelete }) { - const accountName = id => accounts.find(a => a.id === id)?.name || 'Unbekannt'; - const isIncome = type === 'incomes'; - return
-

{isIncome ? 'Fixe Eingänge' : 'Fixe Ausgänge'}

Alle Beträge gelten monatlich und haben bewusst kein Datum.

- {items.length === 0 ? :
{items.map(x => )}
BezeichnungKontoMonatlich
{x.name}{accountName(x.accountId)}{isIncome ? '+' : '−'}{formatMoney(x.amount)}
} -
-} - -function Transfers({ data, onEdit, onDelete }) { - const accountName = id => data.accounts.find(a => a.id === id)?.name || 'Unbekannt'; - return
-

Fixe Transfers

Verschiebungen zwischen eigenen Konten. Global saldoneutral.

- {data.transfers.length === 0 ? :
{data.transfers.map(x => )}
BezeichnungVonNachMonatlich
{x.name}{accountName(x.fromAccountId)}{accountName(x.toAccountId)}{formatMoney(x.amount)}
} -
-} - -function DataPage({ data, importFile }) { - return
-
-

JSON sichern

Alle Daten liegen in einer einzigen JSON-Datei. Vor jedem Speichern wird serverseitig zusätzlich eine .bak-Datei angelegt.

-
JSON herunterladen
-
-

Aktueller Datenstand

{data.accounts.length} Konten · {data.incomes.length} Eingänge · {data.expenses.length} Ausgänge · {data.transfers.length} Transfers

{JSON.stringify(data, null, 2)}
-
-} - -function Empty({ text }) { - return
{text}
-} - -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
e.target === e.currentTarget && onClose()}> -
-
{item ? 'BEARBEITEN' : 'NEU'}

{labels[type]}

- {localError &&
{localError}
} - - {type !== 'accounts' && } - {(type === 'incomes' || type === 'expenses') && } - {type === 'transfers' && <>} -
-
-
-} - createRoot(document.getElementById('root')).render(); diff --git a/src/styles.css b/src/styles.css index c660293..c8e693c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } }