From 616752abb3cf0c2332e7857c8f747320e2fe6c52 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Wed, 19 Aug 2026 23:11:59 +0200 Subject: [PATCH] V3.24 --- public/sw.js | 2 +- server/index.js | 29 +++- src/main.jsx | 447 ++++++++++++++++++++++++++++++------------------ src/styles.css | 56 ++++++ 4 files changed, 356 insertions(+), 178 deletions(-) diff --git a/public/sw.js b/public/sw.js index 9749146..582e141 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const SHELL_CACHE='fixfin-shell-v3.23'; +const SHELL_CACHE='fixfin-shell-v3.24'; const DATA_CACHE='fixfin-data-v1'; const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png']; diff --git a/server/index.js b/server/index.js index b97a432..a759343 100644 --- a/server/index.js +++ b/server/index.js @@ -11,7 +11,9 @@ const pushoverFile=process.env.PUSHOVER_FILE||path.join(dataDir,'pushover.json') const reminderHour=Math.min(23,Math.max(0,Number(process.env.PUSHOVER_REMINDER_HOUR||8))); const PUSHOVER_ENDPOINT='https://api.pushover.net/1/messages.json'; const validIntervals=new Set(['monthly','quarterly','semiannual','yearly']); const validScenarioTypes=new Set(['income','expense','transfer']); -const emptyData=()=>({version:10,settings:{includePeriodicInBalance:true},accounts:[],categories:[],persons:[],incomes:[],expenses:[],transfers:[],scenarios:[]}); +const validAccountOwnerships=new Set(['personal','shared','unassigned']); +const validAccountFunctions=new Set(['giro','reserve','investment','building','other']); +const emptyData=()=>({version:11,settings:{includePeriodicInBalance:true},accounts:[],categories:[],persons:[],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, endDate: typeof item.endDate==='string' && item.endDate ? item.endDate : null }; } @@ -20,6 +22,12 @@ function normalizePersonIds(item={}) { const raw=Array.isArray(item.personIds)?item.personIds:(typeof item.personId==='string'&&item.personId?[item.personId]:[]); return [...new Set(raw.filter(id=>typeof id==='string'&&id))]; } +function normalizeOwnerPersonIds(item={}){const raw=Array.isArray(item.ownerPersonIds)?item.ownerPersonIds:[];return [...new Set(raw.filter(id=>typeof id==='string'&&id))];} +function normalizeAccount(item={}){ + const ownership=validAccountOwnerships.has(item.ownership)?item.ownership:'unassigned'; + const ownerPersonIds=ownership==='unassigned'?[]:normalizeOwnerPersonIds(item); + return {id:item.id,name:item.name,ownership,ownerPersonIds,accountFunction:validAccountFunctions.has(item.accountFunction)?item.accountFunction:'other'}; +} function normalizeCategorizedMovement(item={}) { const normalized=normalizeMovement(item); delete normalized.personId; @@ -37,9 +45,9 @@ function normalizeScenario(item={}) { return {id:item.id,name:item.name,createdA function normalizeData(input){ if(!input||typeof input!=='object')return input; return { - version:10, + version:11, settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false}, - accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts, + accounts:Array.isArray(input.accounts)?input.accounts.map(normalizeAccount):input.accounts, categories:Array.isArray(input.categories)?input.categories.map(({id,name})=>({id,name})):[], persons:Array.isArray(input.persons)?input.persons.map(({id,name})=>({id,name})):[], incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeIncome):input.incomes, @@ -65,11 +73,20 @@ function validateData(input){ if(!input||typeof input!=='object')throw new Error('Ungültiges JSON.'); for(const key of ['accounts','categories','persons','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);} + 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'||!a.name.trim())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);} const personIds=new Set(); const personNames=new Set(); for(const p of input.persons){if(!p.id||typeof p.id!=='string')throw new Error('Person ohne gültige ID.');if(!p.name||typeof p.name!=='string'||!p.name.trim())throw new Error('Person ohne Namen.');if(personIds.has(p.id))throw new Error('Doppelte Personen-ID.');const normalizedName=p.name.trim().toLocaleLowerCase('de-DE');if(personNames.has(normalizedName))throw new Error(`Person '${p.name}' existiert bereits.`);personIds.add(p.id);personNames.add(normalizedName);} + for(const a of input.accounts){ + if(!validAccountOwnerships.has(a.ownership))throw new Error(`Ungültige Eigentumsart bei Konto '${a.name}'.`); + if(!validAccountFunctions.has(a.accountFunction))throw new Error(`Ungültige Kontofunktion bei '${a.name}'.`); + if(!Array.isArray(a.ownerPersonIds))throw new Error(`Ungültige Eigentümerzuordnung bei '${a.name}'.`); + const seen=new Set();for(const id of a.ownerPersonIds){if(typeof id!=='string'||!id||seen.has(id)||!personIds.has(id))throw new Error(`Ungültige Eigentümerzuordnung bei '${a.name}'.`);seen.add(id);} + if(a.ownership==='personal'&&a.ownerPersonIds.length!==1)throw new Error(`Persönliches Konto '${a.name}' braucht genau einen Eigentümer.`); + if(a.ownership==='shared'&&a.ownerPersonIds.length<2)throw new Error(`Gemeinschaftskonto '${a.name}' braucht mindestens zwei Eigentümer.`); + if(a.ownership==='unassigned'&&a.ownerPersonIds.length)throw new Error(`Nicht zugeordnetes Konto '${a.name}' darf keine Eigentümer enthalten.`); + } for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);validateAmountChanges(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}'.`);validatePersonAssignments(item,personIds);if(collection==='expenses'&&typeof item.pushoverReminder!=='boolean')throw new Error(`Ungültige Reminder-Einstellung bei '${item.name}'.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval!=='monthly'&&(item.dueMonth===null||item.dueMonth===undefined))throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Fälligkeitsmonat gesetzt sein.`);} 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}'.`);validatePersonAssignments(t,personIds,'Transfer');} const scenarioIds=new Set(); @@ -99,7 +116,7 @@ async function readPushoverConfig(){const value=await readJsonFile(pushoverFile, async function savePushoverConfig(input){const token=String(input?.token||'').trim();const user=String(input?.user||'').trim();if(!pushoverKeyPattern.test(token))throw new Error('Der Pushover Application API Token muss 30 alphanumerische Zeichen enthalten.');if(!pushoverKeyPattern.test(user))throw new Error('Der Pushover User/Group Key muss 30 alphanumerische Zeichen enthalten.');await writePrivateJson(pushoverFile,{token,user,updatedAt:new Date().toISOString()});return {configured:true,userMasked:maskKey(user)};} async function sendPushover(config,{title,message}){ const body=new URLSearchParams({token:config.token,user:config.user,title,message}); - const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.22'},body}); + const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.24'},body}); let payload={}; try{payload=await response.json();}catch{} if(!response.ok||payload.status!==1)throw new Error(Array.isArray(payload.errors)?payload.errors.join(' '):`Pushover-API Fehler (${response.status})`); return payload; @@ -134,7 +151,7 @@ async function runPushoverReminderCheck(){ } 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);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];const scenarioAdjustments=(parsed.scenarios||[]).flatMap(s=>s.adjustments||[]);if(parsed.version!==10||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.persons)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||movements.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||scenarioAdjustments.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||(parsed.incomes||[]).some(x=>!Array.isArray(x.amountChanges))||(parsed.expenses||[]).some(x=>!Array.isArray(x.amountChanges)||!Object.hasOwn(x,'pushoverReminder'))||movements.some(x=>!Object.hasOwn(x,'endDate'))||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||[])];const scenarioAdjustments=(parsed.scenarios||[]).flatMap(s=>s.adjustments||[]);if(parsed.version!==11||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.persons)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||movements.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||scenarioAdjustments.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||(parsed.incomes||[]).some(x=>!Array.isArray(x.amountChanges))||(parsed.expenses||[]).some(x=>!Array.isArray(x.amountChanges)||!Object.hasOwn(x,'pushoverReminder'))||movements.some(x=>!Object.hasOwn(x,'endDate'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')||!Object.hasOwn(a,'ownership')||!Array.isArray(a.ownerPersonIds)||!Object.hasOwn(a,'accountFunction')))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);}});app.get('/api/report.xlsx',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const personId=typeof req.query.person==='string'&&req.query.person?req.query.person:'all';const includeTransfers=req.query.transfers!=='0';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');if(personId!=='all'&&!data.persons.some(p=>p.id===personId))throw new Error('Bitte eine gültige Person für den Kontoauszug auswählen.');const buffer=await createExcelReport(data,{year,accountId,personId,includeTransfers});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');const personSuffix=personId==='all'?'':`-${data.persons.find(p=>p.id===personId)?.name||'Person'}`.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}${personSuffix}.xlsx"`);res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet').send(Buffer.from(buffer));}catch(e){next(e);}});app.get('/api/report.pdf',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const personId=typeof req.query.person==='string'&&req.query.person?req.query.person:'all';const includeTransfers=req.query.transfers!=='0';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');if(personId!=='all'&&!data.persons.some(p=>p.id===personId))throw new Error('Bitte eine gültige Person für den Kontoauszug auswählen.');const buffer=await createPdfReport(data,{year,accountId,personId,includeTransfers});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');const personSuffix=personId==='all'?'':`-${data.persons.find(p=>p.id===personId)?.name||'Person'}`.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}${personSuffix}.pdf"`);res.type('application/pdf').send(buffer);}catch(e){next(e);}});app.get('/api/pushover/config',async(_req,res,next)=>{try{const config=await readPushoverConfig();res.json(config?{configured:true,userMasked:maskKey(config.user)}:{configured:false,userMasked:''});}catch(e){next(e);}});app.put('/api/pushover/config',async(req,res,next)=>{try{res.json(await savePushoverConfig(req.body));}catch(e){next(e);}});app.delete('/api/pushover/config',async(_req,res,next)=>{try{await fs.rm(pushoverFile,{force:true});res.json({configured:false,userMasked:''});}catch(e){next(e);}});app.post('/api/pushover/test',async(_req,res,next)=>{try{const config=await readPushoverConfig();if(!config)throw new Error('Pushover ist noch nicht konfiguriert.');await sendPushover(config,{title:'FixFin · Test',message:'Pushover ist erfolgreich mit FixFin verbunden.'});res.json({ok:true});}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}`);console.log(`Pushover-Reminder: Prüfung am 1. des Monats ab ${String(reminderHour).padStart(2,'0')}:00 Uhr (${process.env.TZ||'System-Zeitzone'})`);});setTimeout(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),5000);setInterval(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),15*60*1000); diff --git a/src/main.jsx b/src/main.jsx index 3332c97..e5225ac 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -10,7 +10,15 @@ const intervals = { semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 }, yearly: { label: 'Jährlich', divisor: 12, step: 12 }, }; -const emptyData = { version: 10, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], persons: [], incomes: [], expenses: [], transfers: [], scenarios: [] }; +const emptyData = { version: 11, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], persons: [], incomes: [], expenses: [], transfers: [], scenarios: [] }; +const accountFunctions = { + giro: { label:'Giro / Zahlungsverkehr' }, + reserve: { label:'Sparen / Rücklage' }, + investment: { label:'Depot / Investment' }, + building: { label:'Bausparen' }, + other: { label:'Sonstiges' }, +}; +const accountOwnerships = { personal:'Persönlich', shared:'Gemeinsam', unassigned:'Noch nicht zugeordnet' }; function formatMoney(value) { return euro.format(Number(value || 0)); } function parseAmount(value) { @@ -90,6 +98,45 @@ function personFactor(item, personId = 'all') { } function personMatches(item, personId = 'all') { return personFactor(item,personId) > 0; } function accountName(data, id) { return data.accounts.find(a => a.id === id)?.name || 'Unbekannt'; } +function ownerIdsOf(account) { return Array.isArray(account?.ownerPersonIds) ? [...new Set(account.ownerPersonIds.filter(Boolean))] : []; } +function accountOwnerLabel(data, account) { + const ids=ownerIdsOf(account); + if(account?.ownership==='personal') return ids.length?personName(data,ids[0]):'Zuordnung fehlt'; + if(account?.ownership==='shared') return ids.length?ids.map(id=>personName(data,id)).join(', '):'Zuordnung fehlt'; + return 'Zuordnung fehlt'; +} +function accountFunctionLabel(account){return accountFunctions[account?.accountFunction]?.label||accountFunctions.other.label;} +function ownershipDomainKey(account){ + if(!account||!['personal','shared'].includes(account.ownership))return null; + const ids=ownerIdsOf(account).sort(); + if(account.ownership==='personal'&&ids.length!==1)return null; + if(account.ownership==='shared'&&ids.length<2)return null; + return `${account.ownership}:${ids.join('|')}`; +} +function sameOwnershipDomain(a,b){const ak=ownershipDomainKey(a),bk=ownershipDomainKey(b);return Boolean(ak&&bk&&ak===bk);} +function isAssetAccount(account){return ['reserve','investment','building'].includes(account?.accountFunction);} +function assetFunctionName(key){return key==='reserve'?'Rücklage':key==='investment'?'Investment / Depot':key==='building'?'Bausparen':'Vermögen';} +function transferClassification(data,item){ + const from=data.accounts.find(a=>a.id===item.fromAccountId);const to=data.accounts.find(a=>a.id===item.toAccountId); + if(!from||!to||!ownershipDomainKey(from)||!ownershipDomainKey(to))return {key:'unclassified',label:'Nicht klassifiziert',description:'Kontozuordnung fehlt'}; + if(sameOwnershipDomain(from,to)){ + const fromAsset=isAssetAccount(from),toAsset=isAssetAccount(to); + if(toAsset&&!fromAsset){ + if(to.accountFunction==='reserve')return {key:'reserve',label:from.ownership==='shared'?'Zuführung gemeinsame Rücklage':'Zuführung eigene Rücklage',description:'Geld wird in eine Rücklage verschoben'}; + if(to.accountFunction==='investment')return {key:'investment',label:'Zuführung Investment / Depot',description:'Geld wird in eigenes Vermögen verschoben'}; + if(to.accountFunction==='building')return {key:'building',label:'Zuführung Bausparen',description:'Geld wird in eigenes Vermögen verschoben'}; + } + if(fromAsset&&!toAsset)return {key:`${from.accountFunction}-withdrawal`,label:`Entnahme ${assetFunctionName(from.accountFunction)}`,description:'Geld wird aus Vermögen zurück in den Zahlungsverkehr verschoben'}; + if(fromAsset&&toAsset&&from.accountFunction!==to.accountFunction)return {key:'asset-transfer',label:'Vermögensumschichtung',description:'Vermögen wechselt nur zwischen Spar-/Anlagekonten'}; + return {key:'internal',label:'Interne Umbuchung',description:'Gleicher Eigentumsbereich'}; + } + if(from.ownership==='personal'&&to.ownership==='shared')return {key:'shared-funding',label:'Gemeinschaftsfinanzierung',description:'Persönliches Geld finanziert den gemeinsamen Bereich'}; + if(from.ownership==='shared'&&to.ownership==='personal')return {key:'shared-withdrawal',label:'Entnahme Gemeinschaft',description:'Geld verlässt den gemeinsamen Bereich'}; + if(from.ownership==='personal'&&to.ownership==='personal')return {key:'person-transfer',label:'Transfer zwischen Personen',description:'Eigentümer wechselt'}; + if(from.ownership==='shared'&&to.ownership==='shared')return {key:'shared-transfer',label:'Transfer zwischen Gemeinschaften',description:'Gemeinschaftsbereich wechselt'}; + return {key:'other',label:'Sonstiger Transfer',description:'Eigentumsbereich wechselt'}; +} +function transferClassLabel(data,item){return transferClassification(data,item).label;} function calc(data, includeTransfers = true) { const includePeriodic = data.settings?.includePeriodicInBalance !== false; @@ -124,14 +171,37 @@ function calc(data, includeTransfers = true) { return { accounts: [...accountStats.values()], income, expense, transferVolume, periodicExpenseReserve, periodicIncomeAverage, balance: income - expense, includePeriodic, includeTransfers }; } +function sharedCoverageByMonth(data, year, personId) { + const coverage=Array(12).fill(0); + if(personId==='all')return coverage; + for(const group of sharedOwnershipGroups(data)){ + const accountIds=new Set(group.accounts.map(a=>a.id)); + for(let m=0;m<12;m++){ + const groupIncome=(data.incomes||[]).filter(x=>accountIds.has(x.accountId)&&occursInMonth(x,m,year)).reduce((sum,x)=>sum+effectiveAmount(x,year,m),0); + const groupExpenses=(data.expenses||[]).filter(x=>accountIds.has(x.accountId)&&occursInMonth(x,m,year)); + const totalExpense=groupExpenses.reduce((sum,x)=>sum+effectiveAmount(x,year,m),0); + if(totalExpense<=0||groupIncome<=0)continue; + const selectedGross=groupExpenses.reduce((sum,x)=>sum+effectiveAmount(x,year,m)*personFactor(x,personId),0); + if(selectedGross<=0)continue; + coverage[m]+=Math.min(groupIncome,totalExpense)*(selectedGross/totalExpense); + } + } + return coverage; +} + function annualForecast(data, accountId = 'all', year = new Date().getFullYear(), personId = 'all', includeTransfers = true) { const isAll = !accountId || accountId === 'all'; const rows = months.map((name, index) => ({ name, index, income: 0, expense: 0, transferIn: 0, transferOut: 0, balance: 0, items: [] })); - const relevantIncome = item => personMatches(item,personId) && (isAll || item.accountId === accountId); + const incomeFactor = item => { + if(personId==='all')return 1; + const account=data.accounts.find(a=>a.id===item.accountId); + return account?.ownership==='personal'&&ownerIdsOf(account).includes(personId)?1:0; + }; + const relevantIncome = item => incomeFactor(item)>0 && (isAll || item.accountId === accountId); const relevantExpense = item => personMatches(item,personId) && (isAll || item.accountId === accountId); const relevantTransfer = item => personMatches(item,personId) && (isAll || item.fromAccountId === accountId || item.toAccountId === accountId); for (const item of data.incomes.filter(relevantIncome)) { - const factor=personFactor(item,personId); + const factor=incomeFactor(item); for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m)*factor; rows[m].income += amount; rows[m].items.push({ ...item, kind:'income', forecastAmount:amount }); } } for (const item of data.expenses.filter(relevantExpense)) { @@ -150,9 +220,12 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear() } } } - rows.forEach(r => { - r.displayIncome = isAll || !includeTransfers ? r.income : r.income + r.transferIn; - r.displayExpense = isAll || !includeTransfers ? r.expense : r.expense + r.transferOut; + const sharedCoverage=isAll&&personId!=='all'?sharedCoverageByMonth(data,year,personId):Array(12).fill(0); + rows.forEach((r,index) => { + r.sharedCoverage=sharedCoverage[index]||0; + if(r.sharedCoverage>0)r.items.push({id:`shared-coverage-${year}-${index}-${personId}`,name:'Gemeinsame Einnahmen',kind:'coverage',forecastAmount:r.sharedCoverage}); + r.displayIncome = isAll ? r.income : r.income + (includeTransfers ? r.transferIn : 0); + r.displayExpense = isAll ? Math.max(0,r.expense-r.sharedCoverage) : r.expense + (includeTransfers ? r.transferOut : 0); r.balance = r.displayIncome - r.displayExpense; }); const hasMonth = x => Number.isInteger(Number(x.dueMonth)) && Number(x.dueMonth) >= 1 && Number(x.dueMonth) <= 12; @@ -175,10 +248,17 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear() function categoryStats(data, accountId = 'all', personId = 'all', includeTransfers = true) { const categoryMap = new Map((data.categories || []).map(c => [c.id, c.name])); const isAll = accountId === 'all'; - const aggregate = items => { + const accountById=id=>data.accounts.find(a=>a.id===id); + const incomeFactor=item=>{ + if(personId==='all')return 1; + const account=accountById(item.accountId); + return account?.ownership==='personal'&&ownerIdsOf(account).includes(personId)?1:0; + }; + const defaultFactor=item=>personFactor(item,personId); + const aggregate = (items,factorFn=defaultFactor) => { const buckets = new Map(); for (const item of items) { - const factor=personFactor(item,personId); + const factor=Number(item._statFactor??factorFn(item)); if(!factor) continue; const key = item.categoryId || '__uncategorized__'; const name = categoryMap.get(item.categoryId) || 'Ohne Kategorie'; @@ -190,97 +270,103 @@ function categoryStats(data, accountId = 'all', personId = 'all', includeTransfe 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.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'income'}))), - expenses: aggregate(data.expenses.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'expense'}))), - transfers: includeTransfers ? aggregate(data.transfers.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'transfer'}))) : {total:0,rows:[]}, - transfersMerged:false, includeTransfers - }; - const incomeItems = [ - ...data.incomes.filter(x => personMatches(x,personId) && x.accountId === accountId).map(x=>({...x,_statType:'income'})), - ...(includeTransfers ? data.transfers.filter(x => personMatches(x,personId) && x.toAccountId === accountId).map(x=>({...x,_statType:'transfer-in'})) : []), - ]; - const expenseItems = [ - ...data.expenses.filter(x => personMatches(x,personId) && x.accountId === accountId).map(x=>({...x,_statType:'expense'})), - ...(includeTransfers ? data.transfers.filter(x => personMatches(x,personId) && x.fromAccountId === accountId).map(x=>({...x,_statType:'transfer-out'})) : []), - ]; - return { incomes: aggregate(incomeItems), expenses: aggregate(expenseItems), transfers:{total:0,rows:[]}, transfersMerged:includeTransfers, includeTransfers }; + const incomeItems=(data.incomes||[]).filter(x=>(isAll||x.accountId===accountId)&&incomeFactor(x)>0).map(x=>({...x,_statType:'income',_statFactor:incomeFactor(x)})); + const expenseItems=(data.expenses||[]).filter(x=>(isAll||x.accountId===accountId)&&defaultFactor(x)>0).map(x=>({...x,_statType:'expense',_statFactor:defaultFactor(x)})); + const transferItems=includeTransfers?(data.transfers||[]).filter(x=>defaultFactor(x)>0&&(isAll||x.fromAccountId===accountId||x.toAccountId===accountId)).map(x=>{ + const direction=isAll?'neutral':x.toAccountId===accountId?'in':'out'; + return {...x,_statType:direction==='in'?'transfer-in':direction==='out'?'transfer-out':'transfer',_statFactor:defaultFactor(x)}; + }):[]; + return { incomes:aggregate(incomeItems,x=>x._statFactor), expenses:aggregate(expenseItems,x=>x._statFactor), transfers:aggregate(transferItems,x=>x._statFactor), includeTransfers }; } -function personFlowStats(data, accountId = 'all', personId = 'all', includeTransfers = true) { - const isAll = accountId === 'all'; - const unassignedId = '__unassigned__'; - const buckets = new Map(); - const ensure = id => { - const key = id || unassignedId; - if (!buckets.has(key)) buckets.set(key, { - id:key, - name:key===unassignedId?'Ohne Person':personName(data,key), - income:0, expense:0, transferIn:0, transferOut:0, transferInternal:0, - displayIn:0, displayOut:0, net:0, - }); - return buckets.get(key); - }; - for (const person of data.persons || []) ensure(person.id); - - const splitTargets = item => { - const ids=personIdsOf(item); - if(!ids.length) return personId==='all' ? [[unassignedId,1]] : []; - if(personId!=='all') return ids.includes(personId) ? [[personId,1/ids.length]] : []; - return ids.map(id=>[id,1/ids.length]); - }; - - for (const item of data.incomes || []) { - if (!isAll && item.accountId !== accountId) continue; - const amount=monthlyEquivalent(item); - for(const [id,factor] of splitTargets(item)) ensure(id).income += amount*factor; +function sharedOwnershipGroups(data){ + const groups=new Map(); + for(const account of data.accounts||[]){ + if(account.ownership!=='shared'||ownerIdsOf(account).length<2)continue; + const key=ownershipDomainKey(account);if(!key)continue; + if(!groups.has(key))groups.set(key,{key,ownerPersonIds:ownerIdsOf(account),accounts:[]}); + groups.get(key).accounts.push(account); } - for (const item of data.expenses || []) { - if (!isAll && item.accountId !== accountId) continue; - const amount=monthlyEquivalent(item); - for(const [id,factor] of splitTargets(item)) ensure(id).expense += amount*factor; - } - if (includeTransfers) for (const item of data.transfers || []) { - const amount = monthlyEquivalent(item); - for(const [id,factor] of splitTargets(item)) { - const bucket = ensure(id); const share=amount*factor; - if (isAll) bucket.transferInternal += share; - else { - if (item.toAccountId === accountId) bucket.transferIn += share; - if (item.fromAccountId === accountId) bucket.transferOut += share; - } + return [...groups.values()]; +} +function sharedFinanceStats(data, personId='all'){ + const groups=sharedOwnershipGroups(data).map(group=>{ + const accountIds=new Set(group.accounts.map(a=>a.id)); + const income=(data.incomes||[]).filter(x=>accountIds.has(x.accountId)).reduce((sum,x)=>sum+monthlyEquivalent(x),0); + const expenses=(data.expenses||[]).filter(x=>accountIds.has(x.accountId)); + const expense=expenses.reduce((sum,x)=>sum+monthlyEquivalent(x),0); + const internalTransfers=(data.transfers||[]).filter(x=>accountIds.has(x.fromAccountId)&&accountIds.has(x.toAccountId)).reduce((sum,x)=>sum+monthlyEquivalent(x),0); + const incomingTransfers=(data.transfers||[]).filter(x=>accountIds.has(x.toAccountId)&&!accountIds.has(x.fromAccountId)); + const outgoingTransfers=(data.transfers||[]).filter(x=>accountIds.has(x.fromAccountId)&&!accountIds.has(x.toAccountId)); + const fundingIn=incomingTransfers.reduce((sum,x)=>sum+monthlyEquivalent(x),0); + const withdrawalOut=outgoingTransfers.reduce((sum,x)=>sum+monthlyEquivalent(x),0); + const costNeed=Math.max(0,expense-income); + const netAssetChange=income+fundingIn-expense-withdrawalOut; + const shares=new Map(); + const ensure=id=>{const key=id||'__unassigned__';if(!shares.has(key))shares.set(key,{id:key,name:key==='__unassigned__'?'Ohne Person':personName(data,key),grossCost:0,targetFunding:0,actualFunding:0,difference:0});return shares.get(key);}; + for(const id of group.ownerPersonIds)ensure(id); + for(const item of expenses){ + const amount=monthlyEquivalent(item);const ids=personIdsOf(item); + if(!ids.length){ensure('__unassigned__').grossCost+=amount;continue;} + for(const id of ids)ensure(id).grossCost+=amount/ids.length; } + const totalGross=[...shares.values()].reduce((sum,row)=>sum+row.grossCost,0); + for(const row of shares.values())row.targetFunding=totalGross>0?costNeed*(row.grossCost/totalGross):0; + for(const item of incomingTransfers){ + const from=data.accounts.find(a=>a.id===item.fromAccountId);if(from?.ownership!=='personal')continue; + const amount=monthlyEquivalent(item);const ids=ownerIdsOf(from); + if(!ids.length){ensure('__unassigned__').actualFunding+=amount;continue;} + for(const id of ids)ensure(id).actualFunding+=amount/ids.length; + } + for(const row of shares.values())row.difference=row.actualFunding-row.targetFunding; + let personRows=[...shares.values()]; + if(personId!=='all')personRows=personRows.filter(x=>x.id===personId); + personRows=personRows.filter(x=>x.grossCost||x.targetFunding||x.actualFunding||group.ownerPersonIds.includes(x.id)); + return {...group,income,expense,costNeed,fundingIn,withdrawalOut,internalTransfers,netAssetChange,personRows}; + }); + return {groups}; +} + +function personFlowStats(data, accountId = 'all', personId = 'all', includeTransfers = true) { + const isAll=accountId==='all'; + if(!isAll){ + const buckets=new Map();const ensure=id=>{const key=id||'__unassigned__';if(!buckets.has(key))buckets.set(key,{id:key,name:key==='__unassigned__'?'Ohne Person':personName(data,key),income:0,expense:0,transferIn:0,transferOut:0,displayIn:0,displayOut:0,net:0});return buckets.get(key);}; + const splitTargets=item=>{const ids=personIdsOf(item);if(!ids.length)return personId==='all'?[['__unassigned__',1]]:[];if(personId!=='all')return ids.includes(personId)?[[personId,1/ids.length]]:[];return ids.map(id=>[id,1/ids.length]);}; + for(const item of data.incomes||[]){if(item.accountId!==accountId)continue;const amount=monthlyEquivalent(item);for(const [id,factor] of splitTargets(item))ensure(id).income+=amount*factor;} + for(const item of data.expenses||[]){if(item.accountId!==accountId)continue;const amount=monthlyEquivalent(item);for(const [id,factor] of splitTargets(item))ensure(id).expense+=amount*factor;} + if(includeTransfers)for(const item of data.transfers||[]){if(item.fromAccountId!==accountId&&item.toAccountId!==accountId)continue;const amount=monthlyEquivalent(item);for(const [id,factor] of splitTargets(item)){const b=ensure(id),share=amount*factor;if(item.toAccountId===accountId)b.transferIn+=share;if(item.fromAccountId===accountId)b.transferOut+=share;}} + for(const row of buckets.values()){row.displayIn=row.income+(includeTransfers?row.transferIn:0);row.displayOut=row.expense+(includeTransfers?row.transferOut:0);row.net=row.displayIn-row.displayOut;} + let rows=[...buckets.values()];if(personId!=='all')rows=rows.filter(x=>x.id===personId);else rows=rows.filter(x=>x.displayIn||x.displayOut);rows.sort((a,b)=>b.displayOut-a.displayOut||a.name.localeCompare(b.name,'de')); + const totals=rows.reduce((sum,row)=>({displayIn:sum.displayIn+row.displayIn,displayOut:sum.displayOut+row.displayOut,net:sum.net+row.net}),{displayIn:0,displayOut:0,net:0}); + return {rows,totals,isAll:false,includeTransfers}; } - for (const row of buckets.values()) { - row.displayIn = isAll || !includeTransfers ? row.income : row.income + row.transferIn; - row.displayOut = isAll || !includeTransfers ? row.expense : row.expense + row.transferOut; - row.net = row.displayIn - row.displayOut; - } - let rows = [...buckets.values()]; - if (personId !== 'all') rows = rows.filter(x => x.id === personId); - else rows = rows.filter(x => x.income || x.expense || x.transferIn || x.transferOut || x.transferInternal); - rows.sort((a,b) => b.displayOut - a.displayOut || b.displayIn - a.displayIn || a.name.localeCompare(b.name,'de')); - const totals = rows.reduce((sum,row) => ({ - displayIn:sum.displayIn + row.displayIn, - displayOut:sum.displayOut + row.displayOut, - transferIn:sum.transferIn + row.transferIn, - transferOut:sum.transferOut + row.transferOut, - transferInternal:sum.transferInternal + row.transferInternal, - net:sum.net + row.net, - }), {displayIn:0,displayOut:0,transferIn:0,transferOut:0,transferInternal:0,net:0}); - return { rows, totals, isAll, accountId, personId, includeTransfers }; + + const buckets=new Map();const ensure=id=>{const key=id||'__unassigned__';if(!buckets.has(key))buckets.set(key,{id:key,name:key==='__unassigned__'?'Ohne Person':personName(data,key),personalIncome:0,expenseShare:0,sharedExpenseShare:0,sharedCoverage:0,netCost:0,available:0,sharedFundingTarget:0,sharedFundingActual:0,sharedFundingDifference:0,assetBuild:0});return buckets.get(key);}; + for(const person of data.persons||[])ensure(person.id); + for(const item of data.incomes||[]){const account=data.accounts.find(a=>a.id===item.accountId);if(account?.ownership==='personal'){for(const id of ownerIdsOf(account))ensure(id).personalIncome+=monthlyEquivalent(item);}} + for(const item of data.expenses||[]){const amount=monthlyEquivalent(item),ids=personIdsOf(item);const account=data.accounts.find(a=>a.id===item.accountId);const targets=ids.length?ids:['__unassigned__'];for(const id of targets){const row=ensure(id);const share=amount/targets.length;row.expenseShare+=share;if(account?.ownership==='shared')row.sharedExpenseShare+=share;}} + const shared=sharedFinanceStats(data,'all'); + for(const group of shared.groups){const covered=Math.min(group.income,group.expense);const totalCost=group.personRows.reduce((sum,r)=>sum+r.grossCost,0);for(const pr of group.personRows){const row=ensure(pr.id);row.sharedCoverage+=totalCost>0?covered*(pr.grossCost/totalCost):0;row.sharedFundingTarget+=pr.targetFunding;row.sharedFundingActual+=pr.actualFunding;}} + if(includeTransfers)for(const item of data.transfers||[]){const from=data.accounts.find(a=>a.id===item.fromAccountId),to=data.accounts.find(a=>a.id===item.toAccountId);if(from?.ownership!=='personal'||!sameOwnershipDomain(from,to))continue;const fromAsset=isAssetAccount(from),toAsset=isAssetAccount(to);const delta=!fromAsset&&toAsset?monthlyEquivalent(item):fromAsset&&!toAsset?-monthlyEquivalent(item):0;if(!delta)continue;const ids=ownerIdsOf(from);if(!ids.length){ensure('__unassigned__').assetBuild+=delta;continue;}for(const id of ids)ensure(id).assetBuild+=delta/ids.length;} + for(const row of buckets.values()){row.netCost=Math.max(0,row.expenseShare-row.sharedCoverage);row.available=row.personalIncome-row.netCost;row.sharedFundingDifference=row.sharedFundingActual-row.sharedFundingTarget;} + let rows=[...buckets.values()];if(personId!=='all')rows=rows.filter(x=>x.id===personId);else rows=rows.filter(x=>x.personalIncome||x.expenseShare||x.sharedCoverage||x.sharedFundingActual||x.assetBuild);rows.sort((a,b)=>b.netCost-a.netCost||a.name.localeCompare(b.name,'de')); + const totals=rows.reduce((sum,row)=>({personalIncome:sum.personalIncome+row.personalIncome,expenseShare:sum.expenseShare+row.expenseShare,sharedCoverage:sum.sharedCoverage+row.sharedCoverage,netCost:sum.netCost+row.netCost,available:sum.available+row.available,sharedFundingTarget:sum.sharedFundingTarget+row.sharedFundingTarget,sharedFundingActual:sum.sharedFundingActual+row.sharedFundingActual,assetBuild:sum.assetBuild+row.assetBuild}),{personalIncome:0,expenseShare:0,sharedCoverage:0,netCost:0,available:0,sharedFundingTarget:0,sharedFundingActual:0,assetBuild:0}); + return {rows,totals,isAll:true,includeTransfers}; } function PersonFlowOverview({stats}){ - const description = stats.isAll - ? (stats.includeTransfers?'Wirtschaftliche Sicht pro Person. Gemeinsam zugeordnete Bewegungen werden gleichmäßig geteilt; interne Transfers bleiben separat und saldoneutral.':'Wirtschaftliche Sicht pro Person ohne Transfers. Gemeinsam zugeordnete Bewegungen werden gleichmäßig geteilt.') - : (stats.includeTransfers?'Liquiditätssicht pro Person auf dem gewählten Konto. Gemeinsam zugeordnete Bewegungen werden gleichmäßig geteilt; Transfers zählen als echte Zu- und Abflüsse.':'Personenbezogene Zahlungsströme auf dem gewählten Konto ohne Transfers.'); - return

Personenbezogene Zahlungsströme

{description}

=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat
- {stats.rows.length===0?:
{stats.rows.map(row=>
{row.name}{stats.isAll?'wirtschaftlicher Monatssaldo':'Nettofluss auf diesem Konto'}
=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}
{stats.isAll?<>
Einnahmen+{formatMoney(row.income)}
Ausgaben−{formatMoney(row.expense)}
{stats.includeTransfers&&
Interne Transfers{formatMoney(row.transferInternal)}
}
Saldo=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}
:<>
Zuflüsse+{formatMoney(row.displayIn)}
Abflüsse−{formatMoney(row.displayOut)}
{stats.includeTransfers&&<>
Transfers rein+{formatMoney(row.transferIn)}
Transfers raus−{formatMoney(row.transferOut)}
}}
)}
} -
; + if(!stats.isAll)return

Personenbezogene Kontoflüsse

Auf einem einzelnen Konto werden tatsächliche Zu- und Abflüsse gezeigt. Transfers bleiben als Transfers getrennt erkennbar.

=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat
{stats.rows.length===0?:
{stats.rows.map(row=>
{row.name}Nettofluss auf diesem Konto
=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}
Zuflüsse+{formatMoney(row.displayIn)}
Abflüsse−{formatMoney(row.displayOut)}
{stats.includeTransfers&&<>
Transfers rein+{formatMoney(row.transferIn)}
Transfers raus−{formatMoney(row.transferOut)}
}
)}
}
; + return

Persönliche wirtschaftliche Sicht

Eigene Einnahmen entstehen nur auf persönlich zugeordneten Konten. Gemeinsame Einnahmen erhöhen kein persönliches Einkommen, sondern reduzieren die gemeinsame Kostenlast.

=0?'good-text':'bad-text'}`}>{stats.totals.available>=0?'+':''}{formatMoney(stats.totals.available)} / Monat
{stats.rows.length===0?:
{stats.rows.map(row=>
{row.name}Persönliche Einnahmen minus wirtschaftliche Kostenlast
=0?'good-text':'bad-text'}>{row.available>=0?'+':''}{formatMoney(row.available)}
Eigene Kontoeingänge+{formatMoney(row.personalIncome)}
Kostenanteil gesamt−{formatMoney(row.expenseShare)}
Gemeinsame Deckung+{formatMoney(row.sharedCoverage)}
Netto-Kostenlast−{formatMoney(row.netCost)}
{stats.includeTransfers&&<>
Gemeinschaft finanziert{formatMoney(row.sharedFundingActual)}
Vermögensaufbau netto=0?'good-text':'bad-text'}>{row.assetBuild>=0?'+':''}{formatMoney(row.assetBuild)}
}
)}
}
; } +function SharedFundingOverview({data,personId='all',showTransfers=true}){ + const stats=useMemo(()=>sharedFinanceStats(data,personId),[data,personId]); + if(!stats.groups.length)return null; + return

Gemeinschaftsfinanzierung

Gemeinsame Einnahmen wie Kindergeld bleiben im gemeinsamen Topf. Sie reduzieren den Finanzierungsbedarf, werden aber keiner Person als persönliche Einnahme zugerechnet.

{stats.groups.map(group=>
{group.accounts.map(a=>a.name).join(' + ')}{group.ownerPersonIds.map(id=>personName(data,id)).join(', ')}
=0?'good-text':'bad-text'}>{group.netAssetChange>=0?'+':''}{formatMoney(group.netAssetChange)} / Monat
Gemeinsame Einnahmen+{formatMoney(group.income)}
Gemeinsame Kosten−{formatMoney(group.expense)}
Kosten-Finanzierungsbedarf{formatMoney(group.costNeed)}
{showTransfers&&
Tatsächliche Finanzierung{formatMoney(group.fundingIn)}
}
{showTransfers&&group.internalTransfers>0&&
Interne Umbuchungen innerhalb dieses Gemeinschaftsbereichs: {formatMoney(group.internalTransfers)} / Monat. Sie sind keine Kosten.
}
{group.personRows.map(row=>
{row.name}
Kostenanteil{formatMoney(row.grossCost)}
Soll-Finanzierung{formatMoney(row.targetFunding)}
{showTransfers&&<>
Ist-Finanzierung{formatMoney(row.actualFunding)}
Differenz=0?'good-text':'bad-text'}>{row.difference>=0?'+':''}{formatMoney(row.difference)}
}
)}
{!showTransfers&&
Transferdetails sind ausgeblendet. Soll-Finanzierung und Kostenbedarf bleiben sichtbar, Ist-Finanzierung wird nicht angezeigt.
}
)}
; +} + + function dashboardKpis(data, totals) { const forecast = annualForecast(data, 'all'); const expensive = [...forecast.rows].sort((a,b)=>b.displayExpense-a.displayExpense)[0]; @@ -378,8 +464,11 @@ function NavGroupMenu({group,tab,onClose,onSelect}){ const TRANSFER_PREF_KEY='fixfin.include-transfers.v1'; function readTransferPreference(){try{return localStorage.getItem(TRANSFER_PREF_KEY)!=='false';}catch{return true;}} -function TransferToggle({enabled,onChange,compact=false}){ - return
Transfers einbeziehen{enabled?'Transfers werden in konto-bezogenen Zu-/Abflüssen und Auswertungen berücksichtigt.':'Transfers werden aus Berechnungen und Auswertungen ausgeblendet.'}
; +function TransferToggle({enabled,onChange,compact=false,context='display'}){ + const exportMode=context==='export'; + const title=exportMode?'Transfers im Auszug':'Transfer-/Finanzierungsströme anzeigen'; + const copy=exportMode?(enabled?'Transfers werden als eigene Zeilen in PDF und Excel ausgegeben.':'PDF und Excel enthalten nur echte Eingänge und Ausgaben.'):(enabled?'Umbuchungen werden separat sichtbar. Kosten- und Einnahmenstatistiken bleiben davon unberührt.':'Transferdetails sind ausgeblendet. Kontosalden bleiben trotzdem echte Liquiditätssalden.'); + return
{title}{copy}
; } function App() { @@ -392,7 +481,7 @@ function App() { const [dialog,setDialog]=useState(null); const [includeTransfers,setIncludeTransfers]=useState(readTransferPreference); useEffect(()=>{try{localStorage.setItem(TRANSFER_PREF_KEY,String(includeTransfers));}catch{}},[includeTransfers]); - const totals=useMemo(()=>calc(data,includeTransfers),[data,includeTransfers]); + const totals=useMemo(()=>calc(data,true),[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);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)});} @@ -406,6 +495,7 @@ function App() { const without=idList=>(Array.isArray(idList)?idList:[]).filter(x=>x!==id); persist({...data, persons:(data.persons||[]).filter(x=>x.id!==id), + accounts:(data.accounts||[]).map(a=>{const owners=without(ownerIdsOf(a));const invalid=(a.ownership==='personal'&&owners.length!==1)||(a.ownership==='shared'&&owners.length<2);return {...a,ownerPersonIds:invalid?[]:owners,ownership:invalid?'unassigned':a.ownership};}), incomes:data.incomes.map(x=>({...x,personIds:without(personIdsOf(x))})), expenses:data.expenses.map(x=>({...x,personIds:without(personIdsOf(x))})), transfers:data.transfers.map(x=>({...x,personIds:without(personIdsOf(x))})), @@ -428,10 +518,10 @@ function App() { {tab==='accounts'&&setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})} onAddPerson={()=>setDialog({type:'persons'})} onEditPerson={x=>setDialog({type:'persons',item:x})} onDeletePerson={removePerson}/>} {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==='planning'&&} {tab==='stats'&&setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>} {tab==='statements'&&} - {tab==='scenarios'&&} + {tab==='scenarios'&&} {tab==='data'&&} @@ -455,7 +545,7 @@ function Dashboard({data,totals,onToggle,includeTransfers,onTransfersChange}){ 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; - const warnings=useMemo(()=>buildWarnings(data,includeTransfers),[data,includeTransfers]); + const warnings=useMemo(()=>buildWarnings(data,true),[data]); useEffect(()=>{ if(filter.mode==='some'&&accountIds.length>0&&validSelected.length===0) setFilter({mode:'all',ids:[]}); @@ -489,7 +579,7 @@ function Dashboard({data,totals,onToggle,includeTransfers,onTransfersChange}){ -

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)}
+

Salden

{allSelected?'Alle Konten im monatlichen Liquiditätssaldo. Transfers sind enthalten und heben sich über alle Konten gegenseitig auf.':'Nur die ausgewählten Konten. Transfers verändern den Saldo des jeweiligen Kontos.'}

{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)}
{includeTransfers&&<>
Transfers rein+{formatMoney(s.transferIn)}
Transfers raus−{formatMoney(s.transferOut)}
}
)}
}
@@ -500,46 +590,46 @@ function Dashboard({data,totals,onToggle,includeTransfers,onTransfersChange}){ ; } -function PlanningPage({data,totals,includeTransfers,onTransfersChange}){ +function PlanningPage({data,totals}){ const reserves=useMemo(()=>reserveOverview(data),[data]); - const totalRequired=totals.accounts.reduce((s,x)=>s+x.requiredOutflow,0); + const totalRequired=totals.expense; const totalFundingNeed=totals.accounts.reduce((s,x)=>s+x.fundingNeed,0); return
- -
- - 0} positive={totalFundingNeed===0}/> - - -
-

Benötigt pro Konto

{includeTransfers?'Liquiditätsbedarf je Konto: echte Ausgaben plus Transfers raus, gedeckt durch echte Eingänge plus Transfers rein.':'Kontobedarf ohne Transfers: nur echte Ausgaben und echte Eingänge werden berücksichtigt.'}

{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
)}
}
+
Liquiditätsplanung: Transfers zählen hier immer als echte Kontobewegungen. Eine Überweisung zu Depot, Bausparer oder Gemeinschaftskonto reduziert deshalb den Monatssaldo des Quellkontos, ohne zur Kostenstatistik zu werden.
+
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`}
Echte Ausgaben{formatMoney(s.expense)}
Transfers raus{formatMoney(s.transferOut)}
Echte Eingänge{formatMoney(s.income)}
Transfers rein{formatMoney(s.transferIn)}
Zusätzliche Zuführung0?'bad-text':'good-text'}>{formatMoney(s.fundingNeed)}
)}
}
+ +

Rücklagenübersicht

Periodische echte 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 PersonMultiSelect({persons,selectedIds,onChange}){ +function PersonMultiSelect({persons,selectedIds,onChange,label='Personen',help='Mehrere Personen möglich. In personenbezogenen Auswertungen wird der Betrag gleichmäßig auf alle ausgewählten Personen verteilt.'}){ const selected=new Set(selectedIds||[]); const toggle=id=>onChange(selected.has(id)?[...selected].filter(x=>x!==id):[...selected,id]); return
- Personen + {label} {(persons||[]).length===0?Noch keine Personen angelegt.:
{persons.map(person=>{const active=selected.has(person.id);return })}
} - Mehrere Personen möglich. In personenbezogenen Auswertungen wird der Betrag gleichmäßig auf alle ausgewählten Personen verteilt. + {help&&{help}}
; } function Accounts({data,onEdit,onDelete,onAdd,onAddPerson,onEditPerson,onDeletePerson}){ const personUsage=id=>data.incomes.filter(x=>personIdsOf(x).includes(id)).length+data.expenses.filter(x=>personIdsOf(x).includes(id)).length+data.transfers.filter(x=>personIdsOf(x).includes(id)).length+(data.scenarios||[]).reduce((sum,s)=>sum+(s.adjustments||[]).filter(x=>personIdsOf(x).includes(id)).length,0); + const ownedAccounts=id=>(data.accounts||[]).filter(a=>ownerIdsOf(a).includes(id)).length; const unassigned=[...data.incomes,...data.expenses,...data.transfers].filter(x=>personIdsOf(x).length===0).length; + const unconfiguredAccounts=(data.accounts||[]).filter(a=>!ownershipDomainKey(a)).length; return
-

Konten

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

{data.accounts.length===0?:
{data.accounts.map(a=>)}
Konto
{a.name}
}
-

Personen

Du kannst beliebig viele Personen hinterlegen. Jede Bewegung kann einer oder mehreren Personen zugeordnet werden, einschließlich Umbuchungen.

{unassigned>0&&
{unassigned} bestehende {unassigned===1?'Bewegung ist':'Bewegungen sind'} noch ohne Person.Diese Altbestände bleiben erhalten und können beim Bearbeiten zugeordnet werden. Neue Bewegungen benötigen mindestens eine Person.
}{(data.persons||[]).length===0?:
{data.persons.map(person=>
{person.name}{personUsage(person.id)} {personUsage(person.id)===1?'zugeordnete Bewegung':'zugeordnete Bewegungen'}
)}
}
+

Konten

Eigentum bestimmt, wem Geld wirtschaftlich gehört. Die Kontofunktion bestimmt, ob eine Umbuchung z. B. Rücklage, Investment oder Bausparen ist.

{unconfiguredAccounts>0&&
{unconfiguredAccounts} {unconfiguredAccounts===1?'Konto braucht':'Konten brauchen'} noch eine Eigentumszuordnung.Bestehende Konten wurden absichtlich nicht automatisch geraten. Bitte einmal bearbeiten, Eigentum festlegen und die passende Kontofunktion wählen.
}{data.accounts.length===0?:
{data.accounts.map(a=>)}
KontoEigentumFunktion
{a.name}{accountOwnerships[a.ownership]||accountOwnerships.unassigned}{accountOwnerLabel(data,a)}{accountFunctionLabel(a)}
}
+

Personen

Personen können Konten besitzen und einzelnen Bewegungen gemeinsam zugeordnet werden.

{unassigned>0&&
{unassigned} bestehende {unassigned===1?'Bewegung ist':'Bewegungen sind'} noch ohne Person.Diese Altbestände bleiben erhalten und können beim Bearbeiten zugeordnet werden. Neue Bewegungen benötigen mindestens eine Person.
}{(data.persons||[]).length===0?:
{data.persons.map(person=>
{person.name}{personUsage(person.id)} {personUsage(person.id)===1?'Bewegung':'Bewegungen'} · {ownedAccounts(person.id)} {ownedAccounts(person.id)===1?'Konto':'Konten'}
)}
}
; } + 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]]; @@ -578,27 +668,39 @@ function Transfers({data,onEdit,onDuplicate,onDelete}){ const categoryNameLocal=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie'; const personLabelLocal=item=>personListLabel(data,item); 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=>)}
BezeichnungPersonenKategorieVonNachIntervallMonatBetragØ / Monat
{x.name}{x.endDate&&bis {formatDate(x.endDate)}}{personLabelLocal(x)}{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)}{x.endDate&&bis {formatDate(x.endDate)}}
{formatMoney(x.amount)}
+ return

Fixe Transfers

Transfers werden automatisch anhand von Eigentum und Kontofunktion klassifiziert. Sie beeinflussen Kontoliquidität, sind aber keine Konsumausgaben.

{data.transfers.length===0?:<> +
{data.transfers.map(x=>{const cls=transferClassification(data,x);return })}
BezeichnungTypPersonenKategorieVonNachIntervallØ / Monat
{x.name}{x.endDate&&bis {formatDate(x.endDate)}}{cls.label}{personLabelLocal(x)}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.fromAccountId)}{accountNameLocal(x.toAccountId)}{intervals[x.interval||'monthly'].label}{formatMoney(monthlyEquivalent(x))}
+
{data.transfers.map(x=>{const cls=transferClassification(data,x);return
+
{x.name}{cls.label}{categoryNameLocal(x.categoryId)}{x.endDate&&bis {formatDate(x.endDate)}}
{formatMoney(x.amount)}
Von{accountNameLocal(x.fromAccountId)}
Nach{accountNameLocal(x.toAccountId)}
-
-
Personen{personLabelLocal(x)}
-
Intervall{intervals[x.interval||'monthly'].label}
-
Fälligkeit{dueText(x)}
-
Ø / Monat{formatMoney(monthlyEquivalent(x))}
-
+
Personen{personLabelLocal(x)}
Einordnung{cls.description}
Intervall{intervals[x.interval||'monthly'].label}
Ø / Monat{formatMoney(monthlyEquivalent(x))}
-
)}
+
})}
}
; } + +function transferTypeStats(data,accountId='all',personId='all'){ + const isAll=accountId==='all';const buckets=new Map(); + for(const item of data.transfers||[]){ + if(!isAll&&item.fromAccountId!==accountId&&item.toAccountId!==accountId)continue; + const factor=personFactor(item,personId);if(!factor)continue; + const cls=transferClassification(data,item);const amount=monthlyEquivalent(item)*factor; + const row=buckets.get(cls.key)||{key:cls.key,label:cls.label,description:cls.description,amount:0,in:0,out:0,count:0}; + row.amount+=amount;row.count+=1;if(!isAll){if(item.toAccountId===accountId)row.in+=amount;if(item.fromAccountId===accountId)row.out+=amount;}buckets.set(cls.key,row); + } + return {rows:[...buckets.values()].sort((a,b)=>b.amount-a.amount),isAll}; +} +function TransferTypeOverview({data,accountId='all',personId='all'}){ + const stats=useMemo(()=>transferTypeStats(data,accountId,personId),[data,accountId,personId]); + return

Transfer- und Finanzierungsarten

Diese Beträge sind Kontobewegungen, aber keine Kosten. FixFin leitet die Art automatisch aus Eigentum und Kontofunktion ab.

{stats.rows.length===0?:
{stats.rows.map(row=>
{row.label}{row.description}
{formatMoney(row.amount)} / Monat{!stats.isAll&&
rein {formatMoney(row.in)}raus {formatMoney(row.out)}
}
)}
}
; +} + 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)}`; + if(item._statType==='transfer-in') return `Transfer rein · ${transferClassLabel(data,item)} · ${accountName(data,item.fromAccountId)} → ${accountName(data,item.toAccountId)}`; + if(item._statType==='transfer-out') return `Transfer raus · ${transferClassLabel(data,item)} · ${accountName(data,item.fromAccountId)} → ${accountName(data,item.toAccountId)}`; + if(item._statType==='transfer') return `Transfer · ${transferClassLabel(data,item)} · ${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
@@ -611,109 +713,104 @@ function StatisticsPage({data,includeTransfers,onTransfersChange,onAddCategory,o const [personId,setPersonId]=useState('all'); const stats=useMemo(()=>categoryStats(data,accountId,personId,includeTransfers),[data,accountId,personId,includeTransfers]); const personFlows=useMemo(()=>personFlowStats(data,accountId,personId,includeTransfers),[data,accountId,personId,includeTransfers]); - const totals=useMemo(()=>calc(data,includeTransfers),[data,includeTransfers]); + const totals=useMemo(()=>calc(data,true),[data]); const kpis=useMemo(()=>dashboardKpis(data,totals),[data,totals]); const selectedName=data.accounts.find(a=>a.id===accountId)?.name||'Alle Konten'; const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); 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 personMatchesLocal=x=>personMatches(x,personId); - const relevantEntries=[...data.incomes.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.accountId===accountId)),...data.expenses.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.accountId===accountId)),...(includeTransfers?data.transfers.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.fromAccountId===accountId||x.toAccountId===accountId)):[])]; - const uncategorized=relevantEntries.filter(x=>!x.categoryId).length; + const uncategorized=(stats.incomes.rows.find(x=>x.id==='__uncategorized__')?.count||0)+(stats.expenses.rows.find(x=>x.id==='__uncategorized__')?.count||0)+(includeTransfers?(stats.transfers.rows.find(x=>x.id==='__uncategorized__')?.count||0):0); + const personalIncomeLabel=personId==='all'?'Echte Eingänge Ø / Monat':'Eigene Kontoeingänge Ø / Monat'; return
-

Gesamtkennzahlen

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

- =0} negative={totals.balance<0} featured/> - - - - - -
+

Gesamtkennzahlen

Wirtschaftliche Kennzahlen verwenden nur echte Einnahmen und echte Ausgaben. Umbuchungen verändern diese Werte nicht.

=0} negative={totals.balance<0} featured/>
Statistik für{selectedName} · {selectedPerson}
-
+
- - - {includeTransfers&&!stats.transfersMerged&&} + {accountId==='all'&&} + {includeTransfers&&} + + + {includeTransfers&&}

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'}
)}
}
; } + function YearPage({data,includeTransfers,onTransfersChange}){ const currentYear=new Date().getFullYear(); const [accountId,setAccountId]=useState('all'); const [personId,setPersonId]=useState('all'); const [year,setYear]=useState(currentYear); - const forecast=useMemo(()=>annualForecast(data,accountId,year,personId,includeTransfers),[data,accountId,year,personId,includeTransfers]); + const forecast=useMemo(()=>annualForecast(data,accountId,year,personId,true),[data,accountId,year,personId]); const selectedAccount=data.accounts.find(a=>a.id===accountId); const selectedName=selectedAccount?.name||'Alle Konten'; const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); - 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 personalEconomic=forecast.isAll&&personId!=='all'; + const labels=personalEconomic + ? { income:'Eigene Einnahmen', expense:'Netto-Kostenlast', balance:'Verfügbar', yearIncome:'Eigene Einnahmen / Jahr', yearExpense:'Netto-Kostenlast / Jahr', yearBalance:'Verfügbar / Jahr' } + : 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==='coverage'?'Gemeinsame Deckung':x.kind==='income'?(forecast.isAll?'Eingang':'Echter Eingang'):x.kind==='expense'?(forecast.isAll?'Ausgang':'Echte Ausgabe'):x.direction==='in'?`Transfer rein · ${transferClassLabel(data,x)}`:x.direction==='out'?`Transfer raus · ${transferClassLabel(data,x)}`:`Transfer · ${transferClassLabel(data,x)}`; + const kindSign=x=>x.kind==='coverage'||x.kind==='income'||x.direction==='in'?'+':x.kind==='expense'||x.direction==='out'?'−':''; + const kindTone=x=>x.kind==='coverage'||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.forecastAmount??(Number(x.amount||0)*personFactor(x,personId)))}`; 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}{personListLabel(data,x)}
{kindSign(x)}{formatMoney(x.forecastAmount??x.amount)}
; + const detailRow=(x,i)=>
{kindLabel(x)}{x.name}{x.kind==='coverage'?'Gemeinsame Einnahmen reduzieren die Kostenlast, sind aber kein persönliches Einkommen.':personListLabel(data,x)}
{kindSign(x)}{formatMoney(x.forecastAmount??x.amount)}
; return
Jahresansicht für{selectedName} · {selectedPerson} · {year}
+ {forecast.isAll&&personId!=='all'&&
Personensicht: Gemeinsame Eingänge werden nicht als persönliche Einnahmen gezählt. Sie reduzieren stattdessen anteilig die gemeinsame Kostenlast dieser Person.
}
=0} negative={yearBalance<0}/> - + includeTransfers||x.kind!=='transfer').length}/>
-

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

{forecast.isAll?'Echte Ausgaben pro Monat. Interne Transfers bleiben saldoneutral.':includeTransfers?`Was ${selectedName} pro Monat tatsächlich verlässt: echte Ausgaben plus Transfers raus.`:`Echte Ausgaben von ${selectedName}; Transfers sind ausgeblendet.`}

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

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

{personalEconomic?'Persönlicher Kostenanteil nach Abzug der Deckung durch gemeinsame Einnahmen.':forecast.isAll?'Echte Ausgaben pro Monat. Interne Transfers bleiben saldoneutral.':`Was ${selectedName} pro Monat tatsächlich verlässt: echte Ausgaben plus Transfers raus. Der Schalter blendet nur die Transferdetails ein oder aus.`}

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

Jahresprognose

{forecast.isAll?(includeTransfers?'Wirtschaftliche Sicht über alle Konten: Transfers werden angezeigt, bleiben aber saldoneutral.':'Wirtschaftliche Sicht über alle Konten ohne Transfers.'):includeTransfers?`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.`:`Kontosicht für ${selectedName} ohne Transfers: nur echte Eingänge und Ausgaben.`}

+

Jahresprognose

{personalEconomic?'Persönliche wirtschaftliche Sicht: eigene Kontoeingänge minus zugeordnete Kosten, wobei gemeinsame Einnahmen die gemeinsame Kostenlast anteilig decken.':forecast.isAll?(includeTransfers?'Wirtschaftliche Sicht über alle Konten: Transfers werden separat angezeigt und bleiben saldoneutral.':'Wirtschaftliche Sicht über alle Konten: Transferdetails sind ausgeblendet, die wirtschaftlichen Summen bleiben unverändert.'):`Liquiditätssicht für ${selectedName}: Transfers zählen immer im echten Kontofluss. ${includeTransfers?'Die Transferdetails sind eingeblendet.':'Die Transferdetails sind ausgeblendet.'}`}

{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)}
+ const visibleItems=includeTransfers?r.items:r.items.filter(x=>x.kind!=='transfer'); + const inflows=visibleItems.filter(x=>x.kind==='income'||x.direction==='in'); + const outflows=visibleItems.filter(x=>x.kind==='expense'||x.direction==='out'); + return
{r.name}{visibleItems.length===0?'Keine sichtbaren Fälligkeiten':`${visibleItems.length} ${visibleItems.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)}{includeTransfers&&(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.
}:includeTransfers?<> -
-
Echte Eingänge+{formatMoney(r.income)}
-
Transfers rein+{formatMoney(r.transferIn)}
-
Echte Ausgaben−{formatMoney(r.expense)}
-
Transfers raus−{formatMoney(r.transferOut)}
-
+ {visibleItems.length===0?
In diesem Monat sind keine sichtbaren Fixpositionen fällig. Transferdetails können über den Schalter eingeblendet werden.
:forecast.isAll?<>{visibleItems.map(detailRow)}{includeTransfers&&(r.transferIn>0||r.transferOut>0)&&
Transfervolumen in diesem Monat: {formatMoney(r.transferOut)}. Es bleibt vollständig außerhalb von Einnahmen, Ausgaben und Überschuss.
}:<> +
Echte Eingänge+{formatMoney(r.income)}
{includeTransfers&&
Transfers rein+{formatMoney(r.transferIn)}
}
Echte Ausgaben−{formatMoney(r.expense)}
{includeTransfers&&
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.map(detailRow)}} +
Kontosicht: Der Nettofluss bleibt immer liquiditätsbasiert. Transfers sind keine Kosten, können hier aber separat ein- oder ausgeblendet werden.
+ }
; })}
- {forecast.unassigned.length>0&&

Periodische Positionen ohne Monat

Diese Werte sind keinem konkreten Monat der Jahresprognose zugeordnet.

{forecast.unassigned.map((x,i)=>{chipLabel(x)})}
} + {forecast.unassigned.some(x=>includeTransfers||x.kind!=='transfer')&&

Periodische Positionen ohne Monat

Diese Werte sind keinem konkreten Monat der Jahresprognose zugeordnet.

{forecast.unassigned.filter(x=>includeTransfers||x.kind!=='transfer').map((x,i)=>{chipLabel(x)})}
}
; } -function ScenariosPage({data,persist,includeTransfers,onTransfersChange}){ +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,includeTransfers),[data,includeTransfers]); + const baseTotals=useMemo(()=>calc(data,true),[data]); const projectedData=useMemo(()=>scenarioData(data,scenario),[data,scenario]); - const projected=useMemo(()=>calc(projectedData,includeTransfers),[projectedData,includeTransfers]); + const projected=useMemo(()=>calc(projectedData,true),[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-Liquidität: Transfers werden in Kontosalden immer als echte Zu- oder Abflüsse berücksichtigt, bleiben aber wirtschaftlich saldoneutral.
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}/>
@@ -780,7 +877,7 @@ function StatementsPage({data,includeTransfers,onTransfersChange}){ useEffect(()=>{if(accountId&&!data.accounts.some(a=>a.id===accountId))setAccountId(data.accounts[0]?.id||'');if(!accountId&&data.accounts[0])setAccountId(data.accounts[0].id);},[data.accounts,accountId]); if(!data.accounts.length)return
; const account=accountName(data,accountId);const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId);const query=`year=${encodeURIComponent(year)}&account=${encodeURIComponent(accountId)}&person=${encodeURIComponent(personId)}&transfers=${includeTransfers?'1':'0'}`; - return

Kontoauszug erstellen

Nur die geplanten Bewegungen des gewählten Kontos. Keine Stammdaten, Szenarien oder Systemeinstellungen.

PDF · XLSX
Kontoauszug{account} · {selectedPerson} · {year}

PDF: Jahreswerte und monatlich gruppierte Kontobewegungen {includeTransfers?'inklusive Transfers':'ohne Transfers'}. Bei Person-Filter werden gemeinsam zugeordnete Bewegungen anteilig ausgewiesen. Excel: genau zwei Blätter, Kontoauszug und Monatsübersicht.

Erstellt aus FixFin-Planungsdaten, kein von einer Bank ausgestellter Kontoauszug.

; + return

Kontoauszug erstellen

Nur die geplanten Bewegungen des gewählten Kontos. Keine Stammdaten, Szenarien oder Systemeinstellungen.

PDF · XLSX
Kontoauszug{account} · {selectedPerson} · {year}

PDF: Jahreswerte und monatlich gruppierte Kontobewegungen {includeTransfers?'inklusive Transfers':'ohne Transfers'}. Bei Person-Filter werden gemeinsam zugeordnete Bewegungen anteilig ausgewiesen. Excel: genau zwei Blätter, Kontoauszug und Monatsübersicht.

Erstellt aus FixFin-Planungsdaten, kein von einer Bank ausgestellter Kontoauszug.

; } function DataPage({data,importFile}){ @@ -819,6 +916,9 @@ function EditorDialog({dialog,data,onClose,onSave}){ [categoryId,setCategoryId]=useState(item?.categoryId||''), [newCategoryName,setNewCategoryName]=useState(''), [personIds,setPersonIds]=useState(personIdsOf(item)), + [accountOwnership,setAccountOwnership]=useState(item?.ownership||'personal'), + [accountOwnerPersonIds,setAccountOwnerPersonIds]=useState(ownerIdsOf(item)), + [accountFunction,setAccountFunction]=useState(item?.accountFunction||'giro'), [newPersonName,setNewPersonName]=useState(''), [showNewPerson,setShowNewPerson]=useState(false), [pushoverReminder,setPushoverReminder]=useState(item?.pushoverReminder===true), @@ -834,12 +934,16 @@ function EditorDialog({dialog,data,onClose,onSave}){ if(!name.trim())return setLocalError('Bitte eine Bezeichnung eingeben.'); if(!['accounts','categories','persons'].includes(type)&&(numeric<0||!Number.isFinite(numeric)))return setLocalError('Bitte einen gültigen Betrag eingeben.'); if(!['accounts','categories','persons'].includes(type)&&data.accounts.length===0)return setLocalError('Bitte zuerst ein Konto anlegen.'); + if(type==='accounts'&&data.persons.length===0)return setLocalError('Bitte zuerst mindestens eine Person anlegen, damit das Konto einen Eigentümer bekommt.'); + if(type==='accounts'&&accountOwnership==='unassigned')return setLocalError('Bitte festlegen, ob das Konto persönlich oder gemeinsam ist.'); + if(type==='accounts'&&accountOwnership==='personal'&&accountOwnerPersonIds.length!==1)return setLocalError('Ein persönliches Konto braucht genau eine Person als Eigentümer.'); + if(type==='accounts'&&accountOwnership==='shared'&&accountOwnerPersonIds.length<2)return setLocalError('Ein Gemeinschaftskonto braucht mindestens zwei Personen als Eigentümer.'); if(['incomes','expenses','transfers'].includes(type)&&personIds.length===0&&!newPersonName.trim())return setLocalError('Bitte mindestens eine Person auswählen oder direkt neu anlegen.'); if(type==='transfers'&&fromAccountId===toAccountId)return setLocalError('Quell- und Zielkonto müssen verschieden sein.'); if(type==='expenses'&&pushoverReminder&&interval!=='monthly'&&!dueMonth)return setLocalError('Für den Reminder bitte einen Fälligkeitsmonat festlegen.'); if(endDate&&amountChanges.some(c=>c.effectiveFrom.slice(0,7)>endDate.slice(0,7)))return setLocalError('Eine geplante Betragsänderung liegt nach dem Enddatum.'); let next=structuredClone(data); const id=editing?item.id:uid(); let value; - if(type==='accounts') value={id,name:name.trim()}; + if(type==='accounts') value={id,name:name.trim(),ownership:accountOwnership,ownerPersonIds:[...new Set(accountOwnerPersonIds)],accountFunction}; else if(type==='persons') { const duplicate=(next.persons||[]).some(p=>p.id!==item?.id&&p.name.trim().toLocaleLowerCase('de-DE')===name.trim().toLocaleLowerCase('de-DE')); if(duplicate)return setLocalError('Diese Person existiert bereits.'); 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 { @@ -865,6 +969,7 @@ function EditorDialog({dialog,data,onClose,onSave}){ } const simpleType=type==='accounts'||type==='categories'||type==='persons'; return
e.target===e.currentTarget&&onClose()}>
{dialog.duplicate?'DUPLIZIEREN':editing?'BEARBEITEN':'NEU'}

{labels[type]}

{localError&&
{localError}
} + {type==='accounts'&&
{accountOwnership==='personal'?:}
} {!simpleType&&<>{interval!=='monthly'&&}} {(type==='incomes'||type==='expenses'||type==='transfers')&&<>{categoryId==='__new__'&&}} {(type==='incomes'||type==='expenses'||type==='transfers')&&<> diff --git a/src/styles.css b/src/styles.css index 36cfe09..7665ec2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1087,3 +1087,59 @@ html.modal-open, body.modal-open { overflow:hidden !important; overscroll-behavi .person-new-field { grid-template-columns:1fr; } .person-new-field button { width:100%; } } + +/* v3.24: Kontoeigentum, Transferklassifikation und Gemeinschaftsfinanzierung */ +.account-config-note { display:grid;gap:3px;padding:11px 12px;margin:0 0 13px;border:1px solid #5a4d24;background:#211b0d;border-radius:11px; } +.account-config-note strong { color:#f2dc8a;font-size:11px; } +.account-config-note span { color:#a99b73;font-size:9px;line-height:1.45; } +.account-manager-table td { vertical-align:middle; } +.account-manager-table td:nth-child(2) { min-width:180px; } +.ownership-badge,.account-function-badge,.transfer-class-badge { display:inline-flex;align-items:center;width:max-content;max-width:100%;padding:4px 8px;border-radius:999px;border:1px solid #3b4960;background:#151e2b;color:#c5cfdd;font-size:9px;font-weight:850;white-space:nowrap; } +.ownership-badge.personal { border-color:#375f65;background:#102628;color:#9ed8d4; } +.ownership-badge.shared { border-color:#514879;background:#211c38;color:#d2c8ff; } +.ownership-badge.unassigned { border-color:#66572b;background:#2a2414;color:#e8d58a; } +.account-function-badge { border-color:#38506c;background:#122034;color:#acc8e8; } +.account-owner-copy { display:block;margin-top:5px;color:var(--muted);font-size:9px;line-height:1.35; } +.account-editor-fields { display:grid;gap:2px;padding:12px;margin-bottom:14px;border:1px solid #2e3b50;background:#0c131d;border-radius:12px; } +.account-editor-fields > label:last-child { margin-bottom:0; } + +.transfer-class-badge { font-size:8px; } +.transfer-class-badge.shared-funding { border-color:#4c547d;background:#202445;color:#cbd1ff; } +.transfer-class-badge.shared-withdrawal { border-color:#70424a;background:#301b20;color:#f0b7bf; } +.transfer-class-badge.reserve { border-color:#4a6744;background:#172718;color:#b9d9a9; } +.transfer-class-badge.investment { border-color:#5d4e7a;background:#251d36;color:#d7c2f5; } +.transfer-class-badge.building { border-color:#665d32;background:#2a2515;color:#e8dc91; } +.transfer-class-badge.internal { border-color:#3f5267;background:#152131;color:#b9c9dc; } +.transfer-class-badge.unclassified { border-color:#65572d;background:#292414;color:#e8d58a; } +.transfer-class-badge.person-transfer,.transfer-class-badge.shared-transfer,.transfer-class-badge.other { border-color:#67503a;background:#2a2017;color:#e5c29c; } +.movement-card-head .transfer-class-badge { margin-top:5px; } + +.shared-finance-list { display:grid;gap:12px; } +.shared-finance-card { border:1px solid #30405a;background:#0d141e;border-radius:14px;padding:13px; } +.shared-finance-head { display:flex;justify-content:space-between;gap:14px;align-items:flex-start;padding-bottom:11px;border-bottom:1px solid #202b3b; } +.shared-finance-head > div { min-width:0;display:grid;gap:3px; } +.shared-finance-head > div strong { font-size:13px;line-height:1.3; } +.shared-finance-head > div span { color:var(--muted);font-size:9px; } +.shared-finance-head > strong { white-space:nowrap;font-size:14px; } +.shared-finance-summary { display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-top:11px; } +.shared-finance-summary > div { display:grid;gap:3px;padding:9px;border:1px solid #273447;background:#101925;border-radius:10px; } +.shared-finance-summary span,.shared-person-row > div span { color:var(--muted);font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.04em; } +.shared-finance-summary strong { font-size:11px; } +.shared-internal-note { margin-top:10px;padding:8px 10px;border-radius:9px;background:#151d29;color:#9facbc;font-size:9px;line-height:1.45; } +.shared-person-table { display:grid;gap:6px;margin-top:11px; } +.shared-person-row { display:grid;grid-template-columns:minmax(120px,1.3fr) repeat(4,minmax(90px,1fr));gap:8px;align-items:center;padding:8px 9px;border:1px solid #253246;border-radius:10px;background:#0b121b; } +.shared-person-row.compact { grid-template-columns:minmax(120px,1.3fr) repeat(2,minmax(90px,1fr)); } +.shared-person-row > div { min-width:0;display:grid;gap:2px; } +.shared-person-row > div strong { overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px; } + +@media (max-width:760px) { + .account-manager-table { overflow-x:auto; } + .account-manager-table table { min-width:650px; } + .shared-finance-head { flex-direction:column;gap:6px; } + .shared-finance-summary { grid-template-columns:1fr 1fr; } + .shared-person-row { grid-template-columns:1fr 1fr;gap:7px 10px; } + .shared-person-row > .person-badge { grid-column:1/-1; } + .account-editor-fields { padding:10px; } +} +.transfer-class-badge.reserve-withdrawal,.transfer-class-badge.investment-withdrawal,.transfer-class-badge.building-withdrawal { border-color:#704f4a;background:#2c1e1c;color:#efc0b8; } +.transfer-class-badge.asset-transfer { border-color:#4d526e;background:#1c2033;color:#c4caea; }