diff --git a/README.md b/README.md index 733e3f2..01a2d08 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,13 @@ Danach: `http://DOCKER-SERVER-IP:3080` Vor dem ersten Push: ```bash -sudo docker login git.example.de +docker login git.example.de ``` Dann: ```bash -sudo ./build-and-push-gitea.sh git.example.de owner/fixfin latest +./build-and-push-gitea.sh git.example.de owner/fixfin latest ``` Das Image wird als `git.example.de/owner/fixfin:latest` gepusht. @@ -72,7 +72,7 @@ Das Image wird als `git.example.de/owner/fixfin:latest` gepusht. Standardplattform ist `linux/amd64`. Abweichend z. B.: ```bash -sudo PLATFORM=linux/arm64 ./build-and-push-gitea.sh git.example.de owner/fixfin latest +PLATFORM=linux/arm64 ./build-and-push-gitea.sh git.example.de owner/fixfin latest ``` ## Registry-Image deployen @@ -97,3 +97,14 @@ Für nicht-monatliche Positionen kann optional ein Fälligkeitsmonat angegeben w ## Jahresansicht auf Mobilgeräten Die Jahresprognose verwendet kompakte Monats-Akkordeons. Monat, Eingänge, Ausgänge und Saldo sind immer sichtbar. Fällige Einzelpositionen werden erst beim Aufklappen angezeigt. Die mobile Bottom-Navigation bleibt auf allen Ansichten identisch. + +## Finanzcockpit und Planung (v3.9) + +- Navigation: Eingänge, Ausgänge und Transfers sind unter **Bewegungen** zusammengefasst. +- Übersicht: Kennzahlen, benötigte Zuführung je Konto und Rücklagenübersicht. +- Statistik: Kategorie-Drilldown bis auf einzelne Fixpositionen. Bei einem gewählten Einzelkonto werden Transfers je nach Richtung den Eingängen oder Ausgängen zugerechnet. +- Jahr: Belastungsprofil für die zwölf Monate zusätzlich zu den Monats-Akkordeons. +- Szenarien: Was-wäre-wenn-Anpassungen für zusätzliche Eingänge, Ausgänge und Transfers, ohne den Basisplan zu verändern. +- Bewegungen können dupliziert und anschließend angepasst werden. + +Das JSON-Datenmodell verwendet ab dieser Version `version: 6` und ergänzt das Array `scenarios`. Bestehende Daten werden automatisch migriert und vorher als `.bak` gesichert. diff --git a/index.html b/index.html index a0a1046..cfa4b7a 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,13 @@ + + + + + + + FixFin diff --git a/package.json b/package.json index 6bb8c52..cba78b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fixfin", - "version": "1.3.1", + "version": "1.4.0", "private": true, "type": "module", "scripts": { diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..cbff0b7 Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..bba5b76 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..7fe2a01 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..59f8ef6 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,25 @@ +{ + "name": "FixFin", + "short_name": "FixFin", + "description": "Fixkostenplanung für Konten, regelmäßige Zahlungen und Rücklagen.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#090d14", + "theme_color": "#111827", + "orientation": "any", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..41cc831 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,46 @@ +const SHELL_CACHE='fixfin-shell-v3.14'; +const DATA_CACHE='fixfin-data-v1'; +const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png']; + +self.addEventListener('install',event=>{ + event.waitUntil(caches.open(SHELL_CACHE).then(cache=>cache.addAll(APP_SHELL)).then(()=>self.skipWaiting())); +}); + +self.addEventListener('activate',event=>{ + event.waitUntil(Promise.all([ + caches.keys().then(keys=>Promise.all(keys.filter(key=>key.startsWith('fixfin-shell-')&&key!==SHELL_CACHE).map(key=>caches.delete(key)))), + self.clients.claim(), + ])); +}); + +self.addEventListener('fetch',event=>{ + const request=event.request; + if(request.method!=='GET') return; + const url=new URL(request.url); + if(url.origin!==self.location.origin) return; + + if(url.pathname==='/api/data') { + event.respondWith(fetch(request).then(response=>{ + if(response.ok) caches.open(DATA_CACHE).then(cache=>cache.put(request,response.clone())); + return response; + }).catch(()=>caches.match(request).then(cached=>cached||new Response(JSON.stringify({error:'Offline und keine zwischengespeicherten Daten verfügbar.'}),{status:503,headers:{'Content-Type':'application/json'}})))); + return; + } + if(url.pathname.startsWith('/api/')) return; + + if(request.mode==='navigate') { + event.respondWith(fetch(request).then(response=>{ + caches.open(SHELL_CACHE).then(cache=>cache.put('/',response.clone())); + return response; + }).catch(()=>caches.match('/'))); + return; + } + + event.respondWith(caches.match(request).then(cached=>{ + const network=fetch(request).then(response=>{ + if(response.ok) caches.open(SHELL_CACHE).then(cache=>cache.put(request,response.clone())); + return response; + }).catch(()=>cached); + return cached||network; + })); +}); diff --git a/server/index.js b/server/index.js index b45a8c8..b794144 100644 --- a/server/index.js +++ b/server/index.js @@ -7,16 +7,61 @@ 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 validIntervals=new Set(['monthly','quarterly','semiannual','yearly']); -const emptyData=()=>({version:5,settings:{includePeriodicInBalance:true},accounts:[],categories:[],incomes:[],expenses:[],transfers:[]}); +const validScenarioTypes=new Set(['income','expense','transfer']); +const emptyData=()=>({version:6,settings:{includePeriodicInBalance:true},accounts:[],categories:[],incomes:[],expenses:[],transfers:[],scenarios:[]}); const isFiniteNumber=value=>typeof value==='number'&&Number.isFinite(value); 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 normalizeCategorizedMovement(item={}) { const normalized=normalizeMovement(item); return { ...normalized, categoryId: typeof item.categoryId==='string' && item.categoryId ? item.categoryId : null }; } -function normalizeData(input){if(!input||typeof input!=='object')return input;return {version:5,settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false},accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts,categories:Array.isArray(input.categories)?input.categories.map(({id,name})=>({id,name})):[],incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeCategorizedMovement):input.incomes,expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeCategorizedMovement):input.expenses,transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeCategorizedMovement):input.transfers};} +function normalizeScenarioAdjustment(item={}) { + const type=validScenarioTypes.has(item.type)?item.type:'expense'; + const base={id:item.id,name:item.name,type,amount:Number(item.amount||0)}; + if(type==='transfer') return {...base,fromAccountId:item.fromAccountId,toAccountId:item.toAccountId}; + return {...base,accountId:item.accountId}; +} +function normalizeScenario(item={}) { return {id:item.id,name:item.name,createdAt:typeof item.createdAt==='string'?item.createdAt:null,adjustments:Array.isArray(item.adjustments)?item.adjustments.map(normalizeScenarioAdjustment):[]}; } +function normalizeData(input){ + if(!input||typeof input!=='object')return input; + return { + version:6, + settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false}, + accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts, + categories:Array.isArray(input.categories)?input.categories.map(({id,name})=>({id,name})):[], + incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeCategorizedMovement):input.incomes, + expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeCategorizedMovement):input.expenses, + transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeCategorizedMovement):input.transfers, + scenarios:Array.isArray(input.scenarios)?input.scenarios.map(normalizeScenario):[], + }; +} 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','categories','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);}const categoryIds=new Set();const categoryNames=new Set();for(const c of input.categories){if(!c.id||typeof c.id!=='string')throw new Error('Kategorie ohne gültige ID.');if(!c.name||typeof c.name!=='string'||!c.name.trim())throw new Error('Kategorie ohne Namen.');if(categoryIds.has(c.id))throw new Error('Doppelte Kategorie-ID.');const normalizedName=c.name.trim().toLocaleLowerCase('de-DE');if(categoryNames.has(normalizedName))throw new Error(`Kategorie '${c.name}' existiert bereits.`);categoryIds.add(c.id);categoryNames.add(normalizedName);}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}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie 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.');if(t.categoryId!==null&&t.categoryId!==undefined&&!categoryIds.has(t.categoryId))throw new Error(`Unbekannte Kategorie beim Transfer '${t.name}'.`);}} +function validateData(input){ + if(!input||typeof input!=='object')throw new Error('Ungültiges JSON.'); + for(const key of ['accounts','categories','incomes','expenses','transfers','scenarios'])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);} + const categoryIds=new Set(); const categoryNames=new Set(); + for(const c of input.categories){if(!c.id||typeof c.id!=='string')throw new Error('Kategorie ohne gültige ID.');if(!c.name||typeof c.name!=='string'||!c.name.trim())throw new Error('Kategorie ohne Namen.');if(categoryIds.has(c.id))throw new Error('Doppelte Kategorie-ID.');const normalizedName=c.name.trim().toLocaleLowerCase('de-DE');if(categoryNames.has(normalizedName))throw new Error(`Kategorie '${c.name}' existiert bereits.`);categoryIds.add(c.id);categoryNames.add(normalizedName);} + 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}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie 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.');if(t.categoryId!==null&&t.categoryId!==undefined&&!categoryIds.has(t.categoryId))throw new Error(`Unbekannte Kategorie beim Transfer '${t.name}'.`);} + const scenarioIds=new Set(); + for(const s of input.scenarios){ + if(!s.id||typeof s.id!=='string')throw new Error('Szenario ohne gültige ID.'); + if(!s.name||typeof s.name!=='string'||!s.name.trim())throw new Error('Szenario ohne Namen.'); + if(scenarioIds.has(s.id))throw new Error('Doppelte Szenario-ID.'); scenarioIds.add(s.id); + if(!Array.isArray(s.adjustments))throw new Error(`Szenario '${s.name}' hat ungültige Anpassungen.`); + const adjustmentIds=new Set(); + for(const a of s.adjustments){ + if(!a.id||typeof a.id!=='string'||adjustmentIds.has(a.id))throw new Error(`Ungültige Anpassungs-ID in '${s.name}'.`); adjustmentIds.add(a.id); + if(!a.name||typeof a.name!=='string'||!a.name.trim())throw new Error(`Anpassung ohne Namen in '${s.name}'.`); + if(!validScenarioTypes.has(a.type))throw new Error(`Ungültige Anpassungsart in '${s.name}'.`); + if(!isFiniteNumber(a.amount)||a.amount<0)throw new Error(`Ungültiger Szenario-Betrag bei '${a.name}'.`); + if(a.type==='transfer') { if(!accountIds.has(a.fromAccountId)||!accountIds.has(a.toAccountId))throw new Error(`Unbekanntes Konto bei Szenario-Transfer '${a.name}'.`); if(a.fromAccountId===a.toAccountId)throw new Error(`Quell- und Zielkonto bei '${a.name}' müssen verschieden sein.`); } + else if(!accountIds.has(a.accountId))throw new Error(`Unbekanntes Konto bei Szenario-Anpassung '${a.name}'.`); + } + } +} 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!==5||!parsed.settings||!Array.isArray(parsed.categories)||[...parsed.incomes||[],...parsed.expenses||[],...parsed.transfers||[]].some(x=>!x.interval)||[...parsed.incomes||[],...parsed.expenses||[],...parsed.transfers||[]].some(x=>!Object.hasOwn(x,'categoryId'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;} +async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];if(parsed.version!==6||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||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 e5ab1ec..0b85ed8 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -10,7 +10,7 @@ const intervals = { semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 }, yearly: { label: 'Jährlich', divisor: 12, step: 12 }, }; -const emptyData = { version: 5, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], incomes: [], expenses: [], transfers: [] }; +const emptyData = { version: 6, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], incomes: [], expenses: [], transfers: [], scenarios: [] }; function formatMoney(value) { return euro.format(Number(value || 0)); } function parseAmount(value) { @@ -32,6 +32,7 @@ function uid() { 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 annualEquivalent(item) { return monthlyEquivalent(item) * 12; } function isPeriodic(item) { return (item.interval || 'monthly') !== 'monthly'; } function balanceAmount(item, includePeriodic) { if (!isPeriodic(item)) return Number(item.amount || 0); @@ -46,27 +47,34 @@ function occursInMonth(item, monthIndex) { const step = intervals[interval]?.step || 12; return monthIndex >= start && (monthIndex - start) % step === 0; } +function categoryName(data, id) { return data.categories.find(c => c.id === id)?.name || 'Ohne Kategorie'; } +function accountName(data, id) { return data.accounts.find(a => a.id === id)?.name || 'Unbekannt'; } function calc(data) { 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 }])); + const accountStats = new Map(data.accounts.map(a => [a.id, { account: a, income: 0, expense: 0, transferIn: 0, transferOut: 0, periodicReserve: 0, balance: 0, requiredOutflow: 0, coveredInflow: 0, fundingNeed: 0, surplus: 0 }])); for (const item of data.incomes) { const stat = accountStats.get(item.accountId); if (!stat) continue; - const value = balanceAmount(item, includePeriodic); stat.income += value; - if (isPeriodic(item)) stat.periodicReserve += monthlyEquivalent(item); + stat.income += balanceAmount(item, includePeriodic); } for (const item of data.expenses) { const stat = accountStats.get(item.accountId); if (!stat) continue; - const value = balanceAmount(item, includePeriodic); stat.expense += value; + stat.expense += balanceAmount(item, includePeriodic); if (isPeriodic(item)) stat.periodicReserve += monthlyEquivalent(item); } for (const item of data.transfers) { 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; + 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; + stat.requiredOutflow = stat.expense + stat.transferOut; + stat.coveredInflow = stat.income + stat.transferIn; + stat.fundingNeed = Math.max(0, stat.requiredOutflow - stat.coveredInflow); + stat.surplus = Math.max(0, stat.coveredInflow - stat.requiredOutflow); } - 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); @@ -81,45 +89,31 @@ function annualForecast(data, accountId = 'all') { const relevantIncome = item => isAll || item.accountId === accountId; const relevantExpense = item => isAll || item.accountId === accountId; const relevantTransfer = item => isAll || item.fromAccountId === accountId || item.toAccountId === accountId; - for (const item of data.incomes.filter(relevantIncome)) { - for (let m=0;m<12;m++) if (occursInMonth(item,m)) { - rows[m].income += Number(item.amount || 0); - rows[m].items.push({ ...item, kind:'income' }); - } + for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].income += Number(item.amount || 0); rows[m].items.push({ ...item, kind:'income' }); } } for (const item of data.expenses.filter(relevantExpense)) { - for (let m=0;m<12;m++) if (occursInMonth(item,m)) { - rows[m].expense += Number(item.amount || 0); - rows[m].items.push({ ...item, kind:'expense' }); - } + for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].expense += Number(item.amount || 0); rows[m].items.push({ ...item, kind:'expense' }); } } for (const item of data.transfers.filter(relevantTransfer)) { for (let m=0;m<12;m++) if (occursInMonth(item,m)) { const amount = Number(item.amount || 0); if (isAll) { - rows[m].transferIn += amount; - rows[m].transferOut += amount; - rows[m].items.push({ ...item, kind:'transfer', direction:'neutral' }); + rows[m].transferIn += amount; rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'neutral' }); } else { - if (item.toAccountId === accountId) { - rows[m].transferIn += amount; - rows[m].items.push({ ...item, kind:'transfer', direction:'in' }); - } - if (item.fromAccountId === accountId) { - rows[m].transferOut += amount; - rows[m].items.push({ ...item, kind:'transfer', direction:'out' }); - } + if (item.toAccountId === accountId) { rows[m].transferIn += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'in' }); } + if (item.fromAccountId === accountId) { rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'out' }); } } } } - rows.forEach(r => { - r.balance = r.income - r.expense + (isAll ? 0 : r.transferIn - r.transferOut); - r.displayIncome = r.income + (isAll ? 0 : r.transferIn); - r.displayExpense = r.expense + (isAll ? 0 : r.transferOut); + // Zwei Perspektiven: + // Gesamtansicht = wirtschaftliche Sicht, Transfers bleiben saldoneutral. + // Einzelkonto = Liquiditätssicht, Transfers sind echte Zu-/Abflüsse des Kontos. + r.displayIncome = isAll ? r.income : r.income + r.transferIn; + r.displayExpense = isAll ? r.expense : r.expense + r.transferOut; + r.balance = r.displayIncome - r.displayExpense; }); - const hasMonth = x => Number.isInteger(Number(x.dueMonth)) && Number(x.dueMonth) >= 1 && Number(x.dueMonth) <= 12; const unassigned = [ ...data.incomes.filter(relevantIncome).map(x => ({...x, kind:'income'})), @@ -132,7 +126,6 @@ function annualForecast(data, accountId = 'all') { return entries; }), ].filter(x => isPeriodic(x) && !hasMonth(x)); - const yearIncome = rows.reduce((s,r)=>s+r.displayIncome,0); const yearExpense = rows.reduce((s,r)=>s+r.displayExpense,0); return { rows, unassigned, yearIncome, yearExpense, isAll, accountId }; @@ -146,115 +139,270 @@ function categoryStats(data, accountId = 'all') { for (const item of items) { const key = item.categoryId || '__uncategorized__'; const name = categoryMap.get(item.categoryId) || 'Ohne Kategorie'; - const current = buckets.get(key) || { id:key, name, amount:0, count:0, uncategorized:key==='__uncategorized__' }; - current.amount += monthlyEquivalent(item); - current.count += 1; - buckets.set(key,current); + const current = buckets.get(key) || { id:key, name, amount:0, count:0, items:[], uncategorized:key==='__uncategorized__' }; + const amount = monthlyEquivalent(item); + current.amount += amount; current.count += 1; current.items.push({...item, statAmount:amount}); buckets.set(key,current); } const total = [...buckets.values()].reduce((sum,x)=>sum+x.amount,0); const rows = [...buckets.values()].sort((a,b)=>b.amount-a.amount).map(x=>({...x,percent:total>0?(x.amount/total)*100:0})); return { total, rows }; }; - - if (isAll) { - return { - incomes: aggregate(data.incomes), - expenses: aggregate(data.expenses), - transfers: aggregate(data.transfers), - transfersMerged: false, - }; - } - - // Bei einer Einzelkonto-Auswertung sind Transfers echte Zu-/Abflüsse - // dieses Kontos: Zielkonto = Eingang, Quellkonto = Ausgang. + if (isAll) return { incomes: aggregate(data.incomes.map(x=>({...x,_statType:'income'}))), expenses: aggregate(data.expenses.map(x=>({...x,_statType:'expense'}))), transfers: aggregate(data.transfers.map(x=>({...x,_statType:'transfer'}))), transfersMerged:false }; const incomeItems = [ - ...data.incomes.filter(x => x.accountId === accountId), - ...data.transfers.filter(x => x.toAccountId === accountId), + ...data.incomes.filter(x => x.accountId === accountId).map(x=>({...x,_statType:'income'})), + ...data.transfers.filter(x => x.toAccountId === accountId).map(x=>({...x,_statType:'transfer-in'})), ]; const expenseItems = [ - ...data.expenses.filter(x => x.accountId === accountId), - ...data.transfers.filter(x => x.fromAccountId === accountId), + ...data.expenses.filter(x => x.accountId === accountId).map(x=>({...x,_statType:'expense'})), + ...data.transfers.filter(x => x.fromAccountId === accountId).map(x=>({...x,_statType:'transfer-out'})), ]; + return { incomes: aggregate(incomeItems), expenses: aggregate(expenseItems), transfers:{total:0,rows:[]}, transfersMerged:true }; +} +function dashboardKpis(data, totals) { + const forecast = annualForecast(data, 'all'); + const expensive = [...forecast.rows].sort((a,b)=>b.displayExpense-a.displayExpense)[0]; + const cheap = [...forecast.rows].sort((a,b)=>a.displayExpense-b.displayExpense)[0]; return { - incomes: aggregate(incomeItems), - expenses: aggregate(expenseItems), - transfers: { total:0, rows:[] }, - transfersMerged: true, + fixedCostRatio: totals.income > 0 ? totals.expense / totals.income * 100 : 0, + annualExpense: data.expenses.reduce((s,x)=>s+annualEquivalent(x),0), + annualIncome: data.incomes.reduce((s,x)=>s+annualEquivalent(x),0), + freeMonthly: totals.balance, + reserveMonthly: totals.periodicExpenseReserve, + expensive, + cheap, }; } +function reserveOverview(data) { + const items = data.expenses.filter(isPeriodic).map(x=>({ ...x, monthlyReserve:monthlyEquivalent(x), annualNeed:annualEquivalent(x) })).sort((a,b)=>b.monthlyReserve-a.monthlyReserve); + return { items, monthly:items.reduce((s,x)=>s+x.monthlyReserve,0), annual:items.reduce((s,x)=>s+x.annualNeed,0) }; +} + +function scenarioData(data, scenario) { + if (!scenario) return data; + const next = { ...data, incomes:[...data.incomes], expenses:[...data.expenses], transfers:[...data.transfers] }; + for (const a of scenario.adjustments || []) { + const common = { id:`scenario-${scenario.id}-${a.id}`, name:a.name, amount:Number(a.amount||0), interval:'monthly', dueMonth:null, categoryId:a.categoryId || null }; + if (a.type === 'income') next.incomes.push({...common,accountId:a.accountId}); + if (a.type === 'expense') next.expenses.push({...common,accountId:a.accountId}); + if (a.type === 'transfer') next.transfers.push({...common,fromAccountId:a.fromAccountId,toAccountId:a.toAccountId}); + } + return next; +} + function Icon({ name }) { const paths = { dashboard:<>, wallet:<>, - plus:<>, minus:<>, transfer:<>, calendar:<>, chart:<>, + flask:<>, + planning:<>, data:<>, }; return ; } function App() { - 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 [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 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)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)});} + 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);return 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)||(data.scenarios||[]).some(s=>(s.adjustments||[]).some(x=>x.accountId===id||x.fromAccountId===id||x.toAccountId===id));if(used)return setError('Das Konto wird noch verwendet. Einträge, Transfers oder Szenario-Anpassungen 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)});} - function removeCategory(id){const used=[...data.incomes,...data.expenses,...data.transfers].filter(x=>x.categoryId===id).length;const message=used?`Kategorie wird bei ${used} ${used===1?'Eintrag':'Einträgen'} entfernt. Kategorie wirklich löschen?`:'Kategorie wirklich löschen?';if(!confirm(message))return;persist({...data,categories:data.categories.filter(x=>x.id!==id),incomes:data.incomes.map(x=>x.categoryId===id?{...x,categoryId:null}:x),expenses:data.expenses.map(x=>x.categoryId===id?{...x,categoryId:null}:x),transfers:data.transfers.map(x=>x.categoryId===id?{...x,categoryId:null}:x)});} + function removeCategory(id){const used=[...data.incomes,...data.expenses,...data.transfers].filter(x=>x.categoryId===id).length;const message=used?`Kategorie wird bei ${used} ${used===1?'Eintrag':'Einträgen'} entfernt. Kategorie wirklich löschen?`:'Kategorie wirklich löschen?';if(!confirm(message))return;persist({...data,categories:data.categories.filter(x=>x.id!==id),incomes:data.incomes.map(x=>x.categoryId===id?{...x,categoryId:null}:x),expenses:data.expenses.map(x=>x.categoryId===id?{...x,categoryId:null}:x),transfers:data.transfers.map(x=>x.categoryId===id?{...x,categoryId:null}:x),scenarios:(data.scenarios||[]).map(s=>({...s,adjustments:(s.adjustments||[]).map(x=>x.categoryId===id?{...x,categoryId:null}:x)}))});} 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 nav=[['dashboard','dashboard','Übersicht'],['accounts','wallet','Konten'],['incomes','plus','Eingänge'],['expenses','minus','Ausgänge'],['transfers','transfer','Transfers'],['year','calendar','Jahr'],['stats','chart','Statistik'],['data','data','Daten']]; + const nav=[['dashboard','dashboard','Übersicht'],['accounts','wallet','Konten'],['movements','transfer','Bewegungen'],['year','calendar','Jahr'],['planning','planning','Planung'],['stats','chart','Statistik'],['scenarios','flask','Szenarien'],['data','data','Daten']]; return
-

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

Fixe Einnahmen, Ausgaben und Transfers ohne Buchungsverlauf.

{!['dashboard','year','stats','data'].includes(tab)&&}
+

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

Fixe Einnahmen, Ausgaben und Transfers ohne Buchungsverlauf.

{tab==='accounts'&&}
{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==='stats'&&setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>} {tab==='data'&&} + {tab==='dashboard'&&} + {tab==='accounts'&&setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>} + {tab==='movements'&&setDialog({type,item:x})} onDuplicate={(type,x)=>setDialog({type,item:{...x,id:null},duplicate:true})} onDelete={removeItem} onAdd={type=>setDialog({type})}/>} + {tab==='year'&&} + {tab==='planning'&&} + {tab==='stats'&&setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>} + {tab==='scenarios'&&} + {tab==='data'&&}
{dialog&&setDialog(null)} onSave={async next=>{await persist(next);setDialog(null);}}/>}
; } -function Dashboard({totals,onToggle}){ - return
-
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/>
+function Dashboard({data,totals,onToggle}){ + const FILTER_KEY='fixfin.dashboard.account-filter.v1'; + const [filter,setFilter]=useState(()=>{ + try { + const stored=JSON.parse(localStorage.getItem(FILTER_KEY)||'null'); + if(stored?.mode==='some'&&Array.isArray(stored.ids)) return {mode:'some',ids:stored.ids}; + } catch {} + return {mode:'all',ids:[]}; + }); + const accountIds=useMemo(()=>data.accounts.map(a=>a.id),[data.accounts]); + const validSelected=useMemo(()=>filter.mode==='all'?accountIds:filter.ids.filter(id=>accountIds.includes(id)),[filter,accountIds]); + const selectedSet=useMemo(()=>new Set(validSelected),[validSelected]); + const visibleAccounts=useMemo(()=>totals.accounts.filter(s=>selectedSet.has(s.account.id)),[totals.accounts,selectedSet]); + const selectedBalance=visibleAccounts.reduce((sum,s)=>sum+s.balance,0); + const allSelected=filter.mode==='all'||validSelected.length===accountIds.length; + + useEffect(()=>{ + if(filter.mode==='some'&&accountIds.length>0&&validSelected.length===0) setFilter({mode:'all',ids:[]}); + },[accountIds.length,filter.mode,validSelected.length]); + useEffect(()=>{ + try { localStorage.setItem(FILTER_KEY,JSON.stringify(allSelected?{mode:'all',ids:[]}:{mode:'some',ids:validSelected})); } catch {} + },[allSelected,validSelected]); + + function toggleAccount(id){ + // Wenn aktuell alle Konten aktiv sind, bedeutet ein Tap auf ein Konto: + // sofort nur dieses Konto anzeigen. So braucht die Einzelauswahl nur einen Tap. + if(allSelected){ + setFilter({mode:'some',ids:[id]}); + return; + } + const base=[...validSelected]; + const next=base.includes(id)?base.filter(x=>x!==id):[...base,id]; + if(next.length===0) return; + if(next.length===accountIds.length) setFilter({mode:'all',ids:[]}); + else setFilter({mode:'some',ids:next}); + } + + const filterLabel=allSelected?'Alle Konten':`${validSelected.length} von ${accountIds.length} Konten`; + return
+
+
Kontenfilter{filterLabel}
+
+ + {data.accounts.map(a=>)} +
+
+ +

Salden

{allSelected?'Alle Konten im monatlichen Fix-Saldo.':'Nur die ausgewählten Konten.'}

{allSelected?'Gesamtsaldo':'Saldo Auswahl'}=0?'good-text':'bad-text'}>{selectedBalance>=0?'+':''}{formatMoney(selectedBalance)}
+ {visibleAccounts.length===0?:
{visibleAccounts.map(s=>

{s.account.name}

monatlicher Fix-Saldo
=0?'good-text':'bad-text'}`}>{s.balance>=0?'+':''}{formatMoney(s.balance)}
Eingänge+{formatMoney(s.income)}
Ausgänge−{formatMoney(s.expense)}
Transfers rein+{formatMoney(s.transferIn)}
Transfers raus−{formatMoney(s.transferOut)}
)}
} +
+ {!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}

monatlicher Fix-Saldo
=0?'good-text':'bad-text'}`}>{s.balance>=0?'+':''}{formatMoney(s.balance)}
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.
; } -function Metric({label,value,positive,negative,featured}){return
{label}{formatMoney(value)}
} -function CountMetric({label,value}){return
{label}{Number(value||0).toLocaleString('de-DE')}
} -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,categories,onEdit,onDelete}){const accountName=id=>accounts.find(a=>a.id===id)?.name||'Unbekannt';const categoryName=id=>categories.find(c=>c.id===id)?.name||'Ohne Kategorie';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=>)}
BezeichnungKategorieKontoIntervallMonatBetragØ / Monat
{x.name}{categoryName(x.categoryId)}{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';const categoryName=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie';return

Fixe Transfers

Verschiebungen zwischen eigenen Konten. Global saldoneutral.

{data.transfers.length===0?:
{data.transfers.map(x=>)}
BezeichnungKategorieVonNachIntervallMonatBetragØ / Monat
{x.name}{categoryName(x.categoryId)}{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 CategoryDistribution({title,subtitle,stats,tone}){ +function PlanningPage({data,totals}){ + const reserves=useMemo(()=>reserveOverview(data),[data]); + const totalRequired=totals.accounts.reduce((s,x)=>s+x.requiredOutflow,0); + const totalFundingNeed=totals.accounts.reduce((s,x)=>s+x.fundingNeed,0); + return
+
+ + 0} positive={totalFundingNeed===0}/> + + +
+

Benötigt pro Konto

Liquiditätsbedarf je Konto: echte Ausgaben plus Transfers raus, gedeckt durch echte Eingänge plus Transfers rein.

{totals.accounts.length===0?:
{totals.accounts.map(s=>
{s.account.name}0?'bad-text':'good-text'}>{s.fundingNeed>0?`${formatMoney(s.fundingNeed)} fehlen`:`${formatMoney(s.surplus)} übrig`}
Abflussbedarf{formatMoney(s.requiredOutflow)}
Geplante Zuflüsse{formatMoney(s.coveredInflow)}
Zusätzliche Zuführung0?'bad-text':'good-text'}>{formatMoney(s.fundingNeed)}
)}
}
+

Rücklagenübersicht

Periodische Ausgaben auf einen gleichmäßigen Monatsbetrag heruntergerechnet.

{formatMoney(reserves.annual)} / Jahr{formatMoney(reserves.monthly)} / Monat
{reserves.items.length===0?:
{reserves.items.map(x=>
{x.name}{accountName(data,x.accountId)} · {categoryName(data,x.categoryId)} · {intervals[x.interval].label}
{formatMoney(x.annualNeed)} / Jahr{formatMoney(x.monthlyReserve)} / Monat
)}
}
+
; +} + +function Metric({label,value,positive,negative,featured}){return
{label}{formatMoney(value)}
} +function TextMetric({label,value,hint}){return
{label}{value}{hint&&{hint}}
} +function CountMetric({label,value}){return
{label}{Number(value||0).toLocaleString('de-DE')}
} + +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 MovementsPage({data,onEdit,onDuplicate,onDelete,onAdd}){ + const [section,setSection]=useState('incomes'); + const tabs=[['incomes','Eingänge',data.incomes.length],['expenses','Ausgänge',data.expenses.length],['transfers','Transfers',data.transfers.length]]; + return
+
{tabs.map(([id,label,count])=>)}
+ {section==='incomes'&&onEdit('incomes',x)} onDuplicate={x=>onDuplicate('incomes',x)} onDelete={onDelete}/>} + {section==='expenses'&&onEdit('expenses',x)} onDuplicate={x=>onDuplicate('expenses',x)} onDelete={onDelete}/>} + {section==='transfers'&&onEdit('transfers',x)} onDuplicate={x=>onDuplicate('transfers',x)} onDelete={onDelete}/>} +
; +} +function Entries({type,items,accounts,categories,onEdit,onDuplicate,onDelete}){ + const accountNameLocal=id=>accounts.find(a=>a.id===id)?.name||'Unbekannt'; + const categoryNameLocal=id=>categories.find(c=>c.id===id)?.name||'Ohne Kategorie'; + const isIncome=type==='incomes'; + const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; + 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=>)}
BezeichnungKategorieKontoIntervallMonatBetragØ / Monat
{x.name}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.accountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{isIncome?'+':'−'}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
+
{items.map(x=>
+
{x.name}{categoryNameLocal(x.categoryId)}
{isIncome?'+':'−'}{formatMoney(x.amount)}
+
+
Konto{accountNameLocal(x.accountId)}
+
Intervall{intervals[x.interval||'monthly'].label}
+
Fälligkeit{dueText(x)}
+
Ø / Monat{formatMoney(monthlyEquivalent(x))}
+
+
+
)}
+ }
; +} + +function Transfers({data,onEdit,onDuplicate,onDelete}){ + const accountNameLocal=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt'; + const categoryNameLocal=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie'; + const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; + return

Fixe Transfers

Verschiebungen zwischen eigenen Konten. Global saldoneutral.

{data.transfers.length===0?:<> +
{data.transfers.map(x=>)}
BezeichnungKategorieVonNachIntervallMonatBetragØ / Monat
{x.name}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.fromAccountId)}{accountNameLocal(x.toAccountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
+
{data.transfers.map(x=>
+
{x.name}{categoryNameLocal(x.categoryId)}
{formatMoney(x.amount)}
+
Von{accountNameLocal(x.fromAccountId)}
Nach{accountNameLocal(x.toAccountId)}
+
+
Intervall{intervals[x.interval||'monthly'].label}
+
Fälligkeit{dueText(x)}
+
Ø / Monat{formatMoney(monthlyEquivalent(x))}
+
+
+
)}
+ }
; +} + +function CategoryDistribution({title,subtitle,stats,tone,data}){ + function itemMeta(item){ + if(item._statType==='transfer-in') return `Transfer rein · ${accountName(data,item.fromAccountId)} → ${accountName(data,item.toAccountId)}`; + if(item._statType==='transfer-out') return `Transfer raus · ${accountName(data,item.fromAccountId)} → ${accountName(data,item.toAccountId)}`; + if(item._statType==='transfer') return `Transfer · ${accountName(data,item.fromAccountId)} → ${accountName(data,item.toAccountId)}`; + return `${item._statType==='income'?'Eingang':'Ausgang'} · ${accountName(data,item.accountId)}`; + } return

{title}

{subtitle}

{formatMoney(stats.total)} / Monat
- {stats.rows.length===0?:
{stats.rows.map((row,index)=>
{index+1}{row.name}{row.count} {row.count===1?'Position':'Positionen'}
{formatMoney(row.amount)}{row.percent.toFixed(1).replace('.',',')} %
)}
} + {stats.rows.length===0?:
{stats.rows.map((row,index)=>
{index+1}{row.name}{row.count} {row.count===1?'Position':'Positionen'}
{formatMoney(row.amount)}{row.percent.toFixed(1).replace('.',',')} %
{row.items.sort((a,b)=>b.statAmount-a.statAmount).map((item,i)=>
{item.name}{itemMeta(item)} · {intervals[item.interval||'monthly'].label}
{formatMoney(item.statAmount)} / Monat
)}
)}
}
; } function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){ const [accountId,setAccountId]=useState('all'); const stats=useMemo(()=>categoryStats(data,accountId),[data,accountId]); + const totals=useMemo(()=>calc(data),[data]); + const kpis=useMemo(()=>dashboardKpis(data,totals),[data,totals]); const selectedName=data.accounts.find(a=>a.id===accountId)?.name||'Alle Konten'; const usage=id=>data.incomes.filter(x=>x.categoryId===id).length+data.expenses.filter(x=>x.categoryId===id).length+data.transfers.filter(x=>x.categoryId===id).length; const relevantEntries=[...data.incomes.filter(x=>accountId==='all'||x.accountId===accountId),...data.expenses.filter(x=>accountId==='all'||x.accountId===accountId),...data.transfers.filter(x=>accountId==='all'||x.fromAccountId===accountId||x.toAccountId===accountId)]; const uncategorized=relevantEntries.filter(x=>!x.categoryId).length; return
+

Gesamtkennzahlen

Die übergeordneten Kennzahlen liegen hier statt auf der Übersicht.

+ =0} negative={totals.balance<0} featured/> + + + + + +
Statistik für{selectedName}
- - - {!stats.transfersMerged&&} + + + {!stats.transfersMerged&&}

Kategorien verwalten

Kategorien gelten für Eingänge, Ausgänge und Transfers gemeinsam.

{data.categories.length===0?:
{data.categories.map(c=>
{c.name}{usage(c.id)} {usage(c.id)===1?'zugeordneter Eintrag':'zugeordnete Einträge'}
)}
}
; } @@ -264,83 +412,142 @@ function YearPage({data}){ const forecast=useMemo(()=>annualForecast(data,accountId),[data,accountId]); const selectedAccount=data.accounts.find(a=>a.id===accountId); const selectedName=selectedAccount?.name||'Alle Konten'; - const kindLabel=x=>x.kind==='income'?'Eingang':x.kind==='expense'?'Ausgang':x.direction==='in'?'Transfer rein':x.direction==='out'?'Transfer raus':'Transfer'; + const labels=forecast.isAll + ? { income:'Einnahmen', expense:'Ausgaben', balance:'Überschuss', yearIncome:'Einnahmen / Jahr', yearExpense:'Ausgaben / Jahr', yearBalance:'Überschuss / Jahr' } + : { income:'Zuflüsse', expense:'Abflüsse', balance:'Nettofluss', yearIncome:'Zuflüsse / Jahr', yearExpense:'Abflüsse / Jahr', yearBalance:'Nettofluss / Jahr' }; + const kindLabel=x=>x.kind==='income'?(forecast.isAll?'Eingang':'Echter Eingang'):x.kind==='expense'?(forecast.isAll?'Ausgang':'Echte Ausgabe'):x.direction==='in'?'Transfer rein':x.direction==='out'?'Transfer raus':'Transfer'; const kindSign=x=>x.kind==='income'||x.direction==='in'?'+':x.kind==='expense'||x.direction==='out'?'−':''; const kindTone=x=>x.kind==='income'||x.direction==='in'?'good-text':x.kind==='expense'||x.direction==='out'?'bad-text':''; const chipLabel=x=>`${x.name} · ${intervals[x.interval||'monthly'].label} · ${kindLabel(x)} · ${formatMoney(x.amount)}`; + const maxExpense=Math.max(1,...forecast.rows.map(r=>r.displayExpense)); + const maxRow=forecast.rows.reduce((a,b)=>b.displayExpense>a.displayExpense?b:a,forecast.rows[0]); + const yearBalance=forecast.yearIncome-forecast.yearExpense; + const detailRow=(x,i)=>
{kindLabel(x)}{x.name}
{kindSign(x)}{formatMoney(x.amount)}
; + return
-
-
Jahresansicht für{selectedName}
- +
Jahresansicht für{selectedName}
+ +
+ + + =0} negative={yearBalance<0}/> +
-
=forecast.yearExpense} negative={forecast.yearIncome
-

Jahresprognose

{forecast.isAll?'Gesamt über alle Konten. Transfers bleiben saldoneutral.':`Prognose für ${selectedName}. Transfers werden als Zu- oder Abfluss dieses Kontos berücksichtigt.`}

-
{forecast.rows.map(r=>
- -
{r.name}{r.items.length===0?'Keine Fälligkeiten':`${r.items.length} ${r.items.length===1?'Position':'Positionen'}`}
-
-
{forecast.isAll?'Eingänge':'Zuflüsse'}+{formatMoney(r.displayIncome)}
-
{forecast.isAll?'Ausgänge':'Abflüsse'}−{formatMoney(r.displayExpense)}
-
Saldo=0?'good-text':'bad-text'}>{r.balance>=0?'+':''}{formatMoney(r.balance)}
+ +

{forecast.isAll?'Belastungsprofil':'Abflussprofil'}

{forecast.isAll?'Echte Ausgaben pro Monat. Interne Transfers bleiben außen vor.':`Was ${selectedName} pro Monat tatsächlich verlässt: echte Ausgaben plus Transfers raus.`}

{forecast.rows.map(r=>
{r.name.slice(0,3)}
{formatMoney(r.displayExpense)}
)}
+ +

Jahresprognose

{forecast.isAll?'Wirtschaftliche Sicht über alle Konten: nur echte Einnahmen und Ausgaben. Transfers verschieben Geld nur innerhalb des Zahlungspools.':`Liquiditätssicht für ${selectedName}: Transfers rein zählen als Zufluss, Transfers raus als Abfluss. Im Detail bleiben sie klar von echten Einnahmen und Ausgaben getrennt.`}

+
{forecast.rows.map(r=>{ + const inflows=r.items.filter(x=>x.kind==='income'||x.direction==='in'); + const outflows=r.items.filter(x=>x.kind==='expense'||x.direction==='out'); + return
{r.name}{r.items.length===0?'Keine Fälligkeiten':`${r.items.length} ${r.items.length===1?'Position':'Positionen'}`}
{labels.income}+{formatMoney(r.displayIncome)}
{labels.expense}−{formatMoney(r.displayExpense)}
{labels.balance}=0?'good-text':'bad-text'}>{r.balance>=0?'+':''}{formatMoney(r.balance)}
+
+ {r.items.length===0?
In diesem Monat sind keine einzelnen Fixpositionen fällig.
:forecast.isAll?<>{r.items.map(detailRow)}{(r.transferIn>0||r.transferOut>0)&&
Interne Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie bleiben vollständig außerhalb von Einnahmen, Ausgaben und Überschuss.
}:<> +
+
Echte Eingänge+{formatMoney(r.income)}
+
Transfers rein+{formatMoney(r.transferIn)}
+
Echte Ausgaben−{formatMoney(r.expense)}
+
Transfers raus−{formatMoney(r.transferOut)}
+
+ {inflows.length>0&&
Zuflüsse+{formatMoney(r.displayIncome)}
{inflows.map(detailRow)}
} + {outflows.length>0&&
Abflüsse−{formatMoney(r.displayExpense)}
{outflows.map(detailRow)}
} +
Kontosicht: Transfers werden im Nettofluss berücksichtigt, bleiben aber als Transfers gekennzeichnet. So siehst du den echten Liquiditätsbedarf des Kontos, ohne sie mit Konsumausgaben zu verwechseln.
+ }
- -
-
- {r.items.length===0?
In diesem Monat sind keine einzelnen Fixpositionen fällig.
:r.items.map((x,i)=>
-
{kindLabel(x)}{x.name}
- {kindSign(x)}{formatMoney(x.amount)} -
)} - {forecast.isAll&&(r.transferIn>0||r.transferOut>0)&&
Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie verändern den Gesamtsaldo nicht.
} -
-
)}
+ ; + })}
{forecast.unassigned.length>0&&

Periodische Positionen ohne Monat

Diese Werte sind keinem konkreten Monat der Jahresprognose zugeordnet.

{forecast.unassigned.map((x,i)=>{chipLabel(x)})}
}
; } -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.categories.length} Kategorien · {data.incomes.length} Eingänge · {data.expenses.length} Ausgänge · {data.transfers.length} Transfers

{JSON.stringify(data,null,2)}
} +function ScenariosPage({data,persist}){ + const scenarios=data.scenarios||[]; + const [selectedId,setSelectedId]=useState(scenarios[0]?.id||''); + const [adjustmentDialog,setAdjustmentDialog]=useState(null); + useEffect(()=>{if(selectedId&&!scenarios.some(s=>s.id===selectedId))setSelectedId(scenarios[0]?.id||'');if(!selectedId&&scenarios[0])setSelectedId(scenarios[0].id);},[scenarios,selectedId]); + const scenario=scenarios.find(s=>s.id===selectedId)||scenarios[0]; + const baseTotals=useMemo(()=>calc(data),[data]); + const projectedData=useMemo(()=>scenarioData(data,scenario),[data,scenario]); + const projected=useMemo(()=>calc(projectedData),[projectedData]); + async function addScenario(){const name=prompt('Name des neuen Szenarios:','Was wäre wenn …');if(!name?.trim())return;const next={...data,scenarios:[...scenarios,{id:uid(),name:name.trim(),createdAt:new Date().toISOString(),adjustments:[]}]};const saved=await persist(next);setSelectedId(saved.scenarios[saved.scenarios.length-1]?.id||'');} + async function renameScenario(){if(!scenario)return;const name=prompt('Neuer Name:',scenario.name);if(!name?.trim())return;await persist({...data,scenarios:scenarios.map(s=>s.id===scenario.id?{...s,name:name.trim()}:s)});} + async function deleteScenario(){if(!scenario||!confirm(`Szenario „${scenario.name}“ wirklich löschen?`))return;await persist({...data,scenarios:scenarios.filter(s=>s.id!==scenario.id)});} + async function deleteAdjustment(id){if(!scenario||!confirm('Anpassung wirklich löschen?'))return;await persist({...data,scenarios:scenarios.map(s=>s.id===scenario.id?{...s,adjustments:(s.adjustments||[]).filter(a=>a.id!==id)}:s)});} + async function saveAdjustment(value){await persist({...data,scenarios:scenarios.map(s=>s.id===scenario.id?{...s,adjustments:adjustmentDialog?.item?.id?(s.adjustments||[]).map(a=>a.id===adjustmentDialog.item.id?value:a):[...(s.adjustments||[]),value]}:s)});setAdjustmentDialog(null);} + return
+
Szenario{scenarios.length?:Noch keines angelegt}
{scenario&&<>}
+ {!scenario?
:<> +
=0} negative={baseTotals.balance<0}/>=0} negative={projected.balance<0} featured/>=0} negative={projected.balance-baseTotals.balance<0}/>
+

Was-wäre-wenn-Anpassungen

Der Basisplan bleibt unverändert. Hier simulierst du zusätzliche Eingänge, Ausgänge oder Transfers.

{(scenario.adjustments||[]).length===0?:
{scenario.adjustments.map(a=>
{a.type==='income'?'Eingang':a.type==='expense'?'Ausgang':'Transfer'}{a.name}{a.type==='transfer'?`${accountName(data,a.fromAccountId)} → ${accountName(data,a.toAccountId)}`:accountName(data,a.accountId)}
{a.type==='income'?'+':a.type==='expense'?'−':''}{formatMoney(a.amount)} / Monat
)}
}
+

Auswirkung je Konto

Vergleich des monatlichen Saldos zwischen Basisplan und Szenario.

{projected.accounts.map(p=>{const b=baseTotals.accounts.find(x=>x.account.id===p.account.id);const diff=p.balance-(b?.balance||0);return
{p.account.name}
Aktuell{formatMoney(b?.balance||0)}
Szenario{formatMoney(p.balance)}
Änderung=0?'good-text':'bad-text'}>{diff>=0?'+':''}{formatMoney(diff)}
})}
+ } + {adjustmentDialog&&scenario&&setAdjustmentDialog(null)} onSave={saveAdjustment}/>} +
; +} + +function ScenarioAdjustmentDialog({data,item,onClose,onSave}){ + const [name,setName]=useState(item?.name||''); const [type,setType]=useState(item?.type||'expense'); 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 [error,setError]=useState(''); + function submit(e){e.preventDefault();const numeric=parseAmount(amount);if(!name.trim())return setError('Bitte eine Bezeichnung eingeben.');if(!Number.isFinite(numeric)||numeric<0)return setError('Bitte einen gültigen Betrag eingeben.');if(type==='transfer'&&fromAccountId===toAccountId)return setError('Quell- und Zielkonto müssen verschieden sein.');const value={id:item?.id||uid(),name:name.trim(),type,amount:numeric};if(type==='transfer'){value.fromAccountId=fromAccountId;value.toAccountId=toAccountId;}else value.accountId=accountId;onSave(value);} + return
e.target===e.currentTarget&&onClose()}>
SZENARIO

{item?'Anpassung bearbeiten':'Anpassung hinzufügen'}

{error&&
{error}
}{type!=='transfer'?:<>}
; +} + +function DataPage({data,importFile}){ + const summary=[ + ['Konten',data.accounts.length], + ['Kategorien',data.categories.length], + ['Eingänge',data.incomes.length], + ['Ausgänge',data.expenses.length], + ['Transfers',data.transfers.length], + ['Szenarien',(data.scenarios||[]).length], + ]; + const json=JSON.stringify(data,null,2); + return
+

Daten sichern

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

JSON herunterladen
+

Aktueller Datenstand

Kurzübersicht über den Inhalt deiner FixFin-Datei.

+
{summary.map(([label,value])=>
{label}{Number(value).toLocaleString('de-DE')}
)}
+
JSON-RohdatenNur zur Kontrolle und Fehlersuche.
{json}
+
JSON-Rohdaten anzeigenNur bei Bedarf öffnen
{json}
+
+
; +} function Empty({text}){return
{text}
} function EditorDialog({dialog,data,onClose,onSave}){ - const type=dialog.type,item=dialog.item; + const type=dialog.type,item=dialog.item; const editing=Boolean(item?.id); 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):''),[categoryId,setCategoryId]=useState(item?.categoryId||''),[newCategoryName,setNewCategoryName]=useState(''),[localError,setLocalError]=useState(''); const labels={accounts:'Konto',categories:'Kategorie',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'}; async function submit(e){ - e.preventDefault();setLocalError(''); - const numeric=parseAmount(amount); + e.preventDefault();setLocalError(''); const numeric=parseAmount(amount); if(!name.trim())return setLocalError('Bitte eine Bezeichnung eingeben.'); if(type!=='accounts'&&type!=='categories'&&(numeric<0||!Number.isFinite(numeric)))return setLocalError('Bitte einen gültigen Betrag eingeben.'); if(!['accounts','categories'].includes(type)&&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; + let next=structuredClone(data); const id=editing?item.id:uid(); let value; if(type==='accounts') value={id,name:name.trim()}; - else if(type==='categories') { - const duplicate=next.categories.some(c=>c.id!==item?.id&&c.name.trim().toLocaleLowerCase('de-DE')===name.trim().toLocaleLowerCase('de-DE')); - if(duplicate)return setLocalError('Diese Kategorie existiert bereits.'); - value={id,name:name.trim()}; - } else { + else if(type==='categories') { const duplicate=next.categories.some(c=>c.id!==item?.id&&c.name.trim().toLocaleLowerCase('de-DE')===name.trim().toLocaleLowerCase('de-DE')); if(duplicate)return setLocalError('Diese Kategorie existiert bereits.'); 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'||type==='transfers') { - let selectedCategoryId=categoryId||null; - if(categoryId==='__new__'){ - const categoryName=newCategoryName.trim(); - if(!categoryName)return setLocalError('Bitte einen Namen für die neue Kategorie eingeben.'); - const existing=next.categories.find(c=>c.name.trim().toLocaleLowerCase('de-DE')===categoryName.toLocaleLowerCase('de-DE')); - if(existing) selectedCategoryId=existing.id; - else { selectedCategoryId=uid(); next.categories.push({id:selectedCategoryId,name:categoryName}); } - } - if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId}; - else value={...common,accountId,categoryId:selectedCategoryId}; - } + let selectedCategoryId=categoryId||null; + if(categoryId==='__new__'){const categoryNameValue=newCategoryName.trim();if(!categoryNameValue)return setLocalError('Bitte einen Namen für die neue Kategorie eingeben.');const existing=next.categories.find(c=>c.name.trim().toLocaleLowerCase('de-DE')===categoryNameValue.toLocaleLowerCase('de-DE'));if(existing) selectedCategoryId=existing.id;else { selectedCategoryId=uid(); next.categories.push({id:selectedCategoryId,name:categoryNameValue}); }} + if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId}; else value={...common,accountId,categoryId:selectedCategoryId}; } - next[type]=item?next[type].map(x=>x.id===item.id?value:x):[...next[type],value]; + next[type]=editing?next[type].map(x=>x.id===item.id?value:x):[...next[type],value]; try{await onSave(next);}catch(e){setLocalError(e.message);} } const simpleType=type==='accounts'||type==='categories'; - return
e.target===e.currentTarget&&onClose()}>
{item?'BEARBEITEN':'NEU'}

{labels[type]}

{localError&&
{localError}
} + return
e.target===e.currentTarget&&onClose()}>
{dialog.duplicate?'DUPLIZIEREN':editing?'BEARBEITEN':'NEU'}

{labels[type]}

{localError&&
{localError}
} {!simpleType&&<>{interval!=='monthly'&&}} {(type==='incomes'||type==='expenses'||type==='transfers')&&<>{categoryId==='__new__'&&}} {(type==='incomes'||type==='expenses')&&} {type==='transfers'&&<>}
; } + +if ('serviceWorker' in navigator) { + window.addEventListener('load',()=>{ + navigator.serviceWorker.register('/sw.js').catch(error=>console.warn('Service Worker konnte nicht registriert werden:',error)); + }); +} + createRoot(document.getElementById('root')).render(); diff --git a/src/styles.css b/src/styles.css index f53f5f5..10be4c4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -383,3 +383,483 @@ code { color:#b8c3ff; } .stats-summary { grid-template-columns:1fr 1fr; } .category-stat-head > div:last-child { flex-direction:column; align-items:flex-end; gap:0; } } + +/* v3.9: Finanzcockpit, Bewegungen, Drilldowns, Szenarien, Belastungsprofil */ +.cockpit-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:14px; } +.text-metric small { color:var(--muted); font-size:11px; margin-top:5px; } +.funding-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; } +.funding-card { background:#0d131c; border:1px solid #29364a; border-radius:14px; padding:15px; min-width:0; } +.funding-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; padding-bottom:12px; border-bottom:1px solid #202a39; } +.funding-head strong:first-child { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.funding-head span { flex:0 0 auto; font-size:11px; font-weight:850; } +.funding-numbers { display:grid; gap:8px; margin-top:12px; } +.funding-numbers div { display:flex; justify-content:space-between; gap:10px; align-items:baseline; } +.funding-numbers span { color:var(--muted); font-size:10px; } +.funding-numbers strong { font-size:12px; white-space:nowrap; } +.reserve-total { display:flex; flex-direction:column; align-items:flex-end; gap:2px; white-space:nowrap; } +.reserve-total span { color:var(--muted); font-size:10px; } +.reserve-total strong { font-size:16px; } +.reserve-list { display:grid; gap:8px; } +.reserve-row { display:flex; justify-content:space-between; align-items:center; gap:16px; padding:11px 12px; background:#0d131c; border:1px solid #263244; border-radius:11px; } +.reserve-row > div:first-child { min-width:0; } +.reserve-row > div:first-child strong { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px; } +.reserve-row > div:first-child span { display:block; color:var(--muted); font-size:9px; margin-top:3px; } +.reserve-row > div:last-child { flex:0 0 auto; text-align:right; } +.reserve-row > div:last-child span { display:block; color:var(--muted); font-size:9px; } +.reserve-row > div:last-child strong { font-size:12px; } + +.movement-toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px; background:#101722; border:1px solid var(--line); border-radius:15px; padding:8px; } +.movement-tabs { display:flex; gap:5px; min-width:0; } +.movement-tabs button { display:flex; align-items:center; gap:7px; border:0; border-radius:10px; background:transparent; color:var(--muted); padding:9px 12px; font-weight:750; } +.movement-tabs button small { min-width:20px; padding:2px 6px; border-radius:999px; background:#202a39; color:#9aa8ba; font-size:9px; } +.movement-tabs button.active { background:#202a39; color:var(--text); } +.movement-tabs button.active small { background:#34415a; color:#dfe5ee; } + +.category-stat-row { border-radius:11px; } +.category-stat-row > summary { list-style:none; cursor:pointer; padding:3px 0; } +.category-stat-row > summary::-webkit-details-marker { display:none; } +.category-stat-row[open] { background:#0c121b; border:1px solid #263244; padding:10px; } +.category-stat-row[open] > summary { padding:0; } +.category-drilldown { display:grid; gap:6px; margin-top:10px; padding-top:9px; border-top:1px solid #202b3b; } +.category-drill-row { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:7px 8px; border-radius:8px; background:#111925; } +.category-drill-row > div { min-width:0; } +.category-drill-row > div strong { display:block; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.category-drill-row > div span { display:block; color:var(--muted); font-size:8px; margin-top:2px; } +.category-drill-row > strong { flex:0 0 auto; font-size:10px; white-space:nowrap; } + +.burden-bars { display:grid; gap:8px; } +.burden-row { display:grid; grid-template-columns:34px minmax(0,1fr) 105px; align-items:center; gap:9px; } +.burden-row > span { color:var(--muted); font-size:9px; font-weight:800; text-transform:uppercase; } +.burden-row > div { height:11px; border-radius:999px; background:#0a1018; border:1px solid #202b3b; overflow:hidden; } +.burden-row > div i { display:block; height:100%; border-radius:inherit; background:linear-gradient(90deg,#5869b7,#8b9cff); } +.burden-row.peak > div i { background:linear-gradient(90deg,#c35c68,#ff7c86); } +.burden-row.peak > span,.burden-row.peak > strong { color:#ff9ba3; } +.burden-row > strong { text-align:right; font-size:10px; white-space:nowrap; } + +.scenario-toolbar { display:flex; align-items:end; justify-content:space-between; gap:14px; background:#101722; border:1px solid var(--line); border-radius:15px; padding:13px; } +.scenario-toolbar > div:first-child { display:grid; gap:5px; min-width:min(420px,55%); } +.scenario-toolbar > div:first-child > span { color:var(--muted); font-size:9px; text-transform:uppercase; font-weight:850; letter-spacing:.06em; } +.scenario-toolbar select,.year-account-filter select { width:100%; background:#0b111a; color:white; border:1px solid #2a374a; border-radius:10px; padding:10px 11px; } +.danger-button { color:#ff929a; } +.scenario-adjustments { display:grid; gap:8px; } +.scenario-adjustment { display:flex; align-items:center; justify-content:space-between; gap:14px; padding:12px; border:1px solid #29364a; background:#0d131c; border-radius:12px; } +.scenario-adjustment > div:first-child { min-width:0; display:grid; grid-template-columns:auto minmax(0,1fr); align-items:center; column-gap:8px; } +.scenario-adjustment > div:first-child strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.scenario-adjustment > div:first-child small { grid-column:2; color:var(--muted); font-size:9px; } +.scenario-adjustment > div:last-child { flex:0 0 auto; text-align:right; } +.scenario-adjustment > div:last-child > strong { font-size:12px; } +.scenario-type { grid-row:1 / span 2; padding:4px 7px; border-radius:999px; font-size:8px; font-weight:850; text-transform:uppercase; background:#202b3b; } +.scenario-type.income { color:var(--good); background:#15392f; } +.scenario-type.expense { color:var(--bad); background:#401d25; } +.scenario-type.transfer { color:#adb9ff; background:#1c2340; } +.scenario-account-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; } +.scenario-account { border:1px solid #29364a; background:#0d131c; border-radius:12px; padding:12px; } +.scenario-account > strong { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin-bottom:9px; } +.scenario-account div { display:flex; justify-content:space-between; gap:9px; padding:4px 0; } +.scenario-account div span { color:var(--muted); font-size:9px; } +.scenario-account div b { font-size:10px; } + +@media (max-width:1050px) { + .cockpit-grid,.funding-grid,.scenario-account-grid { grid-template-columns:1fr 1fr; } +} +@media (max-width:760px) { + .mobile-nav { grid-template-columns:repeat(7,minmax(50px,1fr)); } + .movement-toolbar { align-items:stretch; flex-direction:column; } + .movement-tabs { display:grid; grid-template-columns:repeat(3,1fr); } + .movement-tabs button { justify-content:center; padding:9px 5px; font-size:11px; } + .movement-toolbar > .primary { width:100%; } + .cockpit-grid,.funding-grid,.scenario-account-grid { grid-template-columns:1fr 1fr; gap:8px; } + .scenario-toolbar { align-items:stretch; flex-direction:column; } + .scenario-toolbar > div:first-child { min-width:0; width:100%; } + .scenario-adjustment { align-items:flex-start; flex-direction:column; } + .scenario-adjustment > div:last-child { width:100%; text-align:left; } + .scenario-adjustment .actions { margin-top:5px; text-align:left; } + .scenario-adjustment .actions button { margin-left:0; margin-right:5px; } + .reserve-panel .section-head { align-items:flex-start; } + .reserve-row { gap:8px; } + .burden-row { grid-template-columns:28px minmax(0,1fr) 86px; gap:6px; } + .burden-row > strong { font-size:9px; } +} +@media (max-width:430px) { + .cockpit-grid,.funding-grid,.scenario-account-grid { grid-template-columns:1fr; } + .reserve-panel .section-head { flex-direction:column; } + .reserve-total { align-items:flex-start; } + .reserve-row { align-items:flex-start; flex-direction:column; } + .reserve-row > div:last-child { text-align:left; } + .category-drill-row { align-items:flex-start; flex-direction:column; } + .category-drill-row > strong { align-self:flex-end; } +} + + +/* v3.10: mobile Bewegungen + Daten, Planung als eigener Bereich */ +.movement-mobile-list { display:none; } +.data-summary-grid { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:10px; } +.data-summary-card { min-width:0; background:#0d131c; border:1px solid #29364a; border-radius:12px; padding:13px; } +.data-summary-card span { display:block; color:var(--muted); font-size:9px; font-weight:800; text-transform:uppercase; letter-spacing:.055em; } +.data-summary-card strong { display:block; margin-top:5px; font-size:22px; letter-spacing:-.035em; } +.data-subhead { display:flex; align-items:end; justify-content:space-between; gap:12px; margin:18px 0 8px; } +.data-subhead strong { font-size:13px; } +.data-subhead span { color:var(--muted); font-size:9px; } +.data-json-mobile { display:none; } +.planning-summary { grid-template-columns:repeat(4,minmax(0,1fr)); } + +@media (max-width:1050px) { + .data-summary-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } + .planning-summary { grid-template-columns:1fr 1fr; } +} + +@media (max-width:760px) { + main { min-width:0; overflow-x:hidden; padding:18px 12px 92px; } + .topbar { margin-bottom:16px; } + .topbar h1 { font-size:24px; } + .topbar p { max-width:none; font-size:11px; } + .content-stack { gap:12px; } + .panel { min-width:0; padding:13px; } + + .mobile-nav { + grid-template-columns:repeat(8,minmax(0,1fr)); + width:100%; + max-width:100%; + overflow-x:hidden; + padding-left:3px; + padding-right:3px; + } + .mobile-nav button { + min-width:0; + padding:5px 0; + font-size:8px; + gap:2px; + overflow:hidden; + } + .mobile-nav button span { + width:100%; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + text-align:center; + } + .mobile-nav svg { width:17px; height:17px; } + + .movements-page .movement-toolbar { + padding:6px; + gap:7px; + border-radius:13px; + } + .movement-tabs { gap:3px; } + .movement-tabs button { + min-width:0; + padding:9px 3px; + font-size:10px; + gap:4px; + } + .movement-tabs button small { + min-width:17px; + padding:2px 4px; + font-size:8px; + } + .movement-toolbar > .primary { + min-height:42px; + border-radius:10px; + } + .movement-list-panel .section-head { + margin-bottom:12px; + } + .movement-list-panel .section-head h2 { font-size:17px; } + .movement-list-panel .section-head p { font-size:10px; line-height:1.4; } + .movement-desktop-table { display:none; } + .movement-mobile-list { display:grid; gap:9px; } + .movement-card { + min-width:0; + border:1px solid #29364a; + background:#0d131c; + border-radius:13px; + padding:12px; + } + .movement-card-head { + display:flex; + justify-content:space-between; + align-items:flex-start; + gap:10px; + padding-bottom:10px; + border-bottom:1px solid #202a39; + } + .movement-card-head > div { + min-width:0; + display:flex; + flex-direction:column; + align-items:flex-start; + gap:6px; + } + .movement-card-head > div > strong { + max-width:100%; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + font-size:14px; + } + .movement-card-head > strong { + flex:0 0 auto; + font-size:15px; + white-space:nowrap; + } + .movement-card .category-badge { font-size:8px; padding:3px 6px; } + .movement-card-grid { + display:grid; + grid-template-columns:1fr 1fr; + gap:9px 12px; + padding:11px 0; + } + .movement-card-grid > div { min-width:0; } + .movement-card-grid span, + .transfer-route span { + display:block; + color:#718096; + font-size:8px; + text-transform:uppercase; + letter-spacing:.055em; + font-weight:800; + margin-bottom:3px; + } + .movement-card-grid strong, + .transfer-route strong { + display:block; + font-size:10px; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + } + .movement-card-actions { + display:grid; + grid-template-columns:repeat(3,minmax(0,1fr)); + gap:6px; + padding-top:10px; + border-top:1px solid #202a39; + } + .movement-card-actions button { + min-width:0; + border:1px solid #2a374a; + background:#171f2d; + color:#d8e0ea; + border-radius:9px; + padding:8px 3px; + font-size:9px; + font-weight:750; + } + .movement-card-actions button.danger { color:#ff929a; } + .transfer-route { + display:grid; + grid-template-columns:minmax(0,1fr) 20px minmax(0,1fr); + align-items:center; + gap:6px; + padding:11px 0 2px; + } + .transfer-route > div { min-width:0; } + .route-arrow { + color:#8b9cff !important; + font-size:17px !important; + text-align:center; + margin:0 !important; + } + .transfer-card .movement-card-grid { + grid-template-columns:repeat(3,minmax(0,1fr)); + } + + .data-page .section-head { margin-bottom:12px; } + .data-page .section-head h2 { font-size:17px; } + .data-page .section-head p { font-size:10px; line-height:1.45; } + .data-actions { + display:grid; + grid-template-columns:1fr; + gap:8px; + } + .data-actions .button-link { + width:100%; + min-height:44px; + justify-content:center; + text-align:center; + } + .data-summary-grid { + grid-template-columns:repeat(3,minmax(0,1fr)); + gap:7px; + } + .data-summary-card { + padding:10px; + border-radius:10px; + } + .data-summary-card span { font-size:7px; } + .data-summary-card strong { font-size:19px; } + .data-json-desktop { display:none; } + .data-json-mobile { + display:block; + margin-top:12px; + border:1px solid #29364a; + background:#0d131c; + border-radius:11px; + overflow:hidden; + } + .data-json-mobile > summary { + list-style:none; + cursor:pointer; + padding:12px; + } + .data-json-mobile > summary::-webkit-details-marker { display:none; } + .data-json-mobile > summary span { + display:block; + font-size:11px; + font-weight:800; + } + .data-json-mobile > summary small { + display:block; + color:var(--muted); + font-size:8px; + margin-top:2px; + } + .data-json-mobile .json-preview { + max-height:50vh; + border:0; + border-top:1px solid #202a39; + border-radius:0; + font-size:9px; + padding:10px; + } + + .planning-summary { grid-template-columns:1fr 1fr; gap:8px; } + .planning-summary .metric { min-height:84px; padding:12px; } + .planning-summary .metric strong { font-size:18px; } + .planning-page .funding-grid { grid-template-columns:1fr; } +} + +@media (max-width:430px) { + .data-summary-grid { grid-template-columns:1fr 1fr; } + .planning-summary { grid-template-columns:1fr 1fr; } + .transfer-card .movement-card-grid { grid-template-columns:1fr 1fr; } +} + + +/* v3.12: reduziertes Dashboard + persistenter Mehrfach-Kontofilter + PWA polish */ +.dashboard-account-filter { + border:1px solid #29364a; + background:#0f1621; + border-radius:14px; + overflow:hidden; +} +.dashboard-account-filter > summary { + list-style:none; + cursor:pointer; + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + padding:12px 14px; + user-select:none; +} +.dashboard-account-filter > summary::-webkit-details-marker { display:none; } +.dashboard-account-filter > summary > div { min-width:0; } +.dashboard-account-filter > summary span:first-child { + display:block; + color:var(--muted); + font-size:9px; + font-weight:850; + text-transform:uppercase; + letter-spacing:.075em; +} +.dashboard-account-filter > summary strong { + display:block; + margin-top:2px; + font-size:14px; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; +} +.dashboard-account-filter[open] > summary .accordion-chevron { transform:rotate(180deg); } +.dashboard-filter-options { + display:flex; + flex-wrap:wrap; + gap:7px; + padding:0 14px 14px; + border-top:1px solid #202a39; + padding-top:12px; +} +.filter-chip { + border:1px solid #2e3b50; + background:#121a26; + color:#98a6b8; + border-radius:999px; + padding:7px 10px; + font-size:10px; + font-weight:800; +} +.filter-chip.active { + color:#e9edff; + background:#202a45; + border-color:#5a6cc7; + box-shadow:inset 0 0 0 1px #7587e533; +} +.dashboard-balances .section-head { align-items:flex-end; } +.dashboard-selected-total { flex:0 0 auto; text-align:right; } +.dashboard-selected-total span { + display:block; + color:var(--muted); + font-size:9px; + font-weight:800; + text-transform:uppercase; + letter-spacing:.06em; + margin-bottom:2px; +} +.dashboard-selected-total strong { font-size:23px; white-space:nowrap; letter-spacing:-.035em; } +.dashboard-toggle { margin-top:2px; } +.stats-kpi-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; } +.stats-kpi-grid .metric { min-height:98px; padding:15px; } +.stats-kpi-grid .metric strong { font-size:21px; } + +@media (display-mode: standalone) { + body { overscroll-behavior-y:none; } + .topbar { padding-top:max(0px, env(safe-area-inset-top)); } +} + +@media (max-width:900px) { + .stats-kpi-grid { grid-template-columns:1fr 1fr; } +} + +@media (max-width:760px) { + .dashboard-page { gap:10px; } + .dashboard-account-filter > summary { padding:10px 11px; } + .dashboard-filter-options { padding:10px 11px 11px; gap:6px; } + .filter-chip { padding:7px 9px; font-size:9px; } + .dashboard-balances { padding:12px; } + .dashboard-balances .section-head { margin-bottom:10px; } + .dashboard-balances .section-head h2 { font-size:17px; } + .dashboard-balances .section-head p { font-size:9px; } + .dashboard-selected-total span { font-size:7px; } + .dashboard-selected-total strong { font-size:18px; } + .dashboard-toggle { padding:12px; } + .dashboard-toggle strong { font-size:11px; } + .dashboard-toggle span:not(.switch span) { font-size:9px; line-height:1.35; } + .stats-kpi-grid { grid-template-columns:1fr 1fr; gap:8px; } + .stats-kpi-grid .metric { min-height:82px; padding:11px; } + .stats-kpi-grid .metric span { font-size:9px; } + .stats-kpi-grid .metric strong { font-size:17px; } +} + +@media (max-width:430px) { + .dashboard-balances .section-head { align-items:flex-start; } + .dashboard-selected-total { text-align:left; } + .stats-kpi-grid { grid-template-columns:1fr 1fr; } +} + + +/* v3.14: Jahresansicht trennt Wirtschaftssicht und Konto-Liquidität */ +.year-flow-breakdown { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; padding:10px 0 12px; border-bottom:1px solid #202a39; } +.year-flow-breakdown > div { min-width:0; background:#0b111a; border:1px solid #202c3c; border-radius:10px; padding:9px 10px; } +.year-flow-breakdown span { display:block; color:#7f8da1; font-size:8px; font-weight:800; text-transform:uppercase; letter-spacing:.05em; margin-bottom:4px; } +.year-flow-breakdown strong { display:block; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.year-detail-group { padding-top:10px; } +.year-detail-group + .year-detail-group { margin-top:3px; border-top:1px dashed #2a3546; } +.year-detail-group-title { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:2px 0 4px; } +.year-detail-group-title > span { color:#a9b5c6; font-size:9px; font-weight:850; text-transform:uppercase; letter-spacing:.065em; } +.year-detail-group-title > strong { font-size:11px; white-space:nowrap; } + +@media (max-width:760px) { + .year-flow-breakdown { grid-template-columns:1fr 1fr; gap:6px; } + .year-flow-breakdown > div { padding:8px; } +}