V3.24
This commit is contained in:
+1
-1
@@ -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'];
|
||||
|
||||
|
||||
+23
-6
@@ -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);
|
||||
|
||||
+274
-169
@@ -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,
|
||||
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);
|
||||
}
|
||||
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 buckets.get(key);
|
||||
};
|
||||
for (const person of data.persons || []) ensure(person.id);
|
||||
return {groups};
|
||||
}
|
||||
|
||||
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]);
|
||||
};
|
||||
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 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 <section className="panel person-flow-panel"><div className="section-head"><div><h2>Personenbezogene Zahlungsströme</h2><p>{description}</p></div><strong className={`person-flow-total ${stats.totals.net>=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat</strong></div>
|
||||
{stats.rows.length===0?<Empty text="Noch keine personenbezogenen Werte für diese Auswahl vorhanden."/>:<div className="person-flow-list">{stats.rows.map(row=><div className="person-flow-row" key={row.id}><div className="person-flow-head"><div><span className={`person-badge ${row.id==='__unassigned__'?'muted-person':''}`}>{row.name}</span><small>{stats.isAll?'wirtschaftlicher Monatssaldo':'Nettofluss auf diesem Konto'}</small></div><strong className={row.net>=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}</strong></div><div className="person-flow-grid">{stats.isAll?<><div><span>Einnahmen</span><strong className="good-text">+{formatMoney(row.income)}</strong></div><div><span>Ausgaben</span><strong className="bad-text">−{formatMoney(row.expense)}</strong></div>{stats.includeTransfers&&<div><span>Interne Transfers</span><strong>{formatMoney(row.transferInternal)}</strong></div>}<div><span>Saldo</span><strong className={row.net>=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}</strong></div></>:<><div><span>Zuflüsse</span><strong className="good-text">+{formatMoney(row.displayIn)}</strong></div><div><span>Abflüsse</span><strong className="bad-text">−{formatMoney(row.displayOut)}</strong></div>{stats.includeTransfers&&<><div><span>Transfers rein</span><strong className="good-text">+{formatMoney(row.transferIn)}</strong></div><div><span>Transfers raus</span><strong className="bad-text">−{formatMoney(row.transferOut)}</strong></div></>}</>}</div></div>)}</div>}
|
||||
</section>;
|
||||
if(!stats.isAll)return <section className="panel person-flow-panel"><div className="section-head"><div><h2>Personenbezogene Kontoflüsse</h2><p>Auf einem einzelnen Konto werden tatsächliche Zu- und Abflüsse gezeigt. Transfers bleiben als Transfers getrennt erkennbar.</p></div><strong className={`person-flow-total ${stats.totals.net>=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat</strong></div>{stats.rows.length===0?<Empty text="Noch keine personenbezogenen Werte für diese Auswahl vorhanden."/>:<div className="person-flow-list">{stats.rows.map(row=><div className="person-flow-row" key={row.id}><div className="person-flow-head"><div><span className={`person-badge ${row.id==='__unassigned__'?'muted-person':''}`}>{row.name}</span><small>Nettofluss auf diesem Konto</small></div><strong className={row.net>=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}</strong></div><div className="person-flow-grid"><div><span>Zuflüsse</span><strong className="good-text">+{formatMoney(row.displayIn)}</strong></div><div><span>Abflüsse</span><strong className="bad-text">−{formatMoney(row.displayOut)}</strong></div>{stats.includeTransfers&&<><div><span>Transfers rein</span><strong className="good-text">+{formatMoney(row.transferIn)}</strong></div><div><span>Transfers raus</span><strong className="bad-text">−{formatMoney(row.transferOut)}</strong></div></>}</div></div>)}</div>}</section>;
|
||||
return <section className="panel person-flow-panel"><div className="section-head"><div><h2>Persönliche wirtschaftliche Sicht</h2><p>Eigene Einnahmen entstehen nur auf persönlich zugeordneten Konten. Gemeinsame Einnahmen erhöhen kein persönliches Einkommen, sondern reduzieren die gemeinsame Kostenlast.</p></div><strong className={`person-flow-total ${stats.totals.available>=0?'good-text':'bad-text'}`}>{stats.totals.available>=0?'+':''}{formatMoney(stats.totals.available)} / Monat</strong></div>{stats.rows.length===0?<Empty text="Noch keine personenbezogenen Werte vorhanden."/>:<div className="person-flow-list">{stats.rows.map(row=><div className="person-flow-row" key={row.id}><div className="person-flow-head"><div><span className={`person-badge ${row.id==='__unassigned__'?'muted-person':''}`}>{row.name}</span><small>Persönliche Einnahmen minus wirtschaftliche Kostenlast</small></div><strong className={row.available>=0?'good-text':'bad-text'}>{row.available>=0?'+':''}{formatMoney(row.available)}</strong></div><div className="person-flow-grid"><div><span>Eigene Kontoeingänge</span><strong className="good-text">+{formatMoney(row.personalIncome)}</strong></div><div><span>Kostenanteil gesamt</span><strong className="bad-text">−{formatMoney(row.expenseShare)}</strong></div><div><span>Gemeinsame Deckung</span><strong className="good-text">+{formatMoney(row.sharedCoverage)}</strong></div><div><span>Netto-Kostenlast</span><strong className="bad-text">−{formatMoney(row.netCost)}</strong></div>{stats.includeTransfers&&<><div><span>Gemeinschaft finanziert</span><strong>{formatMoney(row.sharedFundingActual)}</strong></div><div><span>Vermögensaufbau netto</span><strong className={row.assetBuild>=0?'good-text':'bad-text'}>{row.assetBuild>=0?'+':''}{formatMoney(row.assetBuild)}</strong></div></>}</div></div>)}</div>}</section>;
|
||||
}
|
||||
|
||||
function SharedFundingOverview({data,personId='all',showTransfers=true}){
|
||||
const stats=useMemo(()=>sharedFinanceStats(data,personId),[data,personId]);
|
||||
if(!stats.groups.length)return null;
|
||||
return <section className="panel shared-finance-panel"><div className="section-head"><div><h2>Gemeinschaftsfinanzierung</h2><p>Gemeinsame Einnahmen wie Kindergeld bleiben im gemeinsamen Topf. Sie reduzieren den Finanzierungsbedarf, werden aber keiner Person als persönliche Einnahme zugerechnet.</p></div></div><div className="shared-finance-list">{stats.groups.map(group=><article className="shared-finance-card" key={group.key}><div className="shared-finance-head"><div><strong>{group.accounts.map(a=>a.name).join(' + ')}</strong><span>{group.ownerPersonIds.map(id=>personName(data,id)).join(', ')}</span></div><strong className={group.netAssetChange>=0?'good-text':'bad-text'}>{group.netAssetChange>=0?'+':''}{formatMoney(group.netAssetChange)} / Monat</strong></div><div className="shared-finance-summary"><div><span>Gemeinsame Einnahmen</span><strong className="good-text">+{formatMoney(group.income)}</strong></div><div><span>Gemeinsame Kosten</span><strong className="bad-text">−{formatMoney(group.expense)}</strong></div><div><span>Kosten-Finanzierungsbedarf</span><strong>{formatMoney(group.costNeed)}</strong></div>{showTransfers&&<div><span>Tatsächliche Finanzierung</span><strong>{formatMoney(group.fundingIn)}</strong></div>}</div>{showTransfers&&group.internalTransfers>0&&<div className="shared-internal-note">Interne Umbuchungen innerhalb dieses Gemeinschaftsbereichs: {formatMoney(group.internalTransfers)} / Monat. Sie sind keine Kosten.</div>}<div className="shared-person-table">{group.personRows.map(row=><div className={`shared-person-row ${showTransfers?'':'compact'}`} key={row.id}><span className={`person-badge ${row.id==='__unassigned__'?'muted-person':''}`}>{row.name}</span><div><span>Kostenanteil</span><strong>{formatMoney(row.grossCost)}</strong></div><div><span>Soll-Finanzierung</span><strong>{formatMoney(row.targetFunding)}</strong></div>{showTransfers&&<><div><span>Ist-Finanzierung</span><strong>{formatMoney(row.actualFunding)}</strong></div><div><span>Differenz</span><strong className={row.difference>=0?'good-text':'bad-text'}>{row.difference>=0?'+':''}{formatMoney(row.difference)}</strong></div></>}</div>)}</div>{!showTransfers&&<div className="shared-internal-note">Transferdetails sind ausgeblendet. Soll-Finanzierung und Kostenbedarf bleiben sichtbar, Ist-Finanzierung wird nicht angezeigt.</div>}</article>)}</div></section>;
|
||||
}
|
||||
|
||||
|
||||
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 <section className={`toggle-panel transfer-toggle ${compact?'compact-transfer-toggle':''}`}><div><strong>Transfers einbeziehen</strong><span>{enabled?'Transfers werden in konto-bezogenen Zu-/Abflüssen und Auswertungen berücksichtigt.':'Transfers werden aus Berechnungen und Auswertungen ausgeblendet.'}</span></div><button type="button" className={`switch ${enabled?'on':''}`} onClick={()=>onChange(!enabled)} role="switch" aria-checked={enabled} aria-label="Transfers einbeziehen"><span/></button></section>;
|
||||
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 <section className={`toggle-panel transfer-toggle ${compact?'compact-transfer-toggle':''}`}><div><strong>{title}</strong><span>{copy}</span></div><button type="button" className={`switch ${enabled?'on':''}`} onClick={()=>onChange(!enabled)} role="switch" aria-checked={enabled} aria-label={title}><span/></button></section>;
|
||||
}
|
||||
|
||||
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'&&<Accounts data={data} onEdit={x=>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'&&<MovementsPage data={data} onEdit={(type,x)=>setDialog({type,item:x})} onDuplicate={(type,x)=>setDialog({type,item:{...x,id:null},duplicate:true})} onDelete={removeItem} onAdd={type=>setDialog({type})}/>}
|
||||
{tab==='year'&&<YearPage data={data} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
|
||||
{tab==='planning'&&<PlanningPage data={data} totals={totals} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
|
||||
{tab==='planning'&&<PlanningPage data={data} totals={totals}/>}
|
||||
{tab==='stats'&&<StatisticsPage data={data} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers} onAddCategory={()=>setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>}
|
||||
{tab==='statements'&&<StatementsPage data={data} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
|
||||
{tab==='scenarios'&&<ScenariosPage data={data} persist={persist} includeTransfers={includeTransfers} onTransfersChange={setIncludeTransfers}/>}
|
||||
{tab==='scenarios'&&<ScenariosPage data={data} persist={persist}/>}
|
||||
{tab==='data'&&<DataPage data={data} importFile={importFile}/>}
|
||||
</main>
|
||||
<nav className="mobile-nav">{MAIN_NAV.map(item=><button key={item.tab||item.menu} className={mainActive(item)?'active':''} onClick={()=>activateMain(item)}><Icon name={item.icon}/><span>{item.label}</span></button>)}</nav>
|
||||
@@ -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}){
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<section className="panel dashboard-balances"><div className="section-head"><div><h2>Salden</h2><p>{allSelected?'Alle Konten im monatlichen Fix-Saldo.':'Nur die ausgewählten Konten.'}</p></div><div className="dashboard-selected-total"><span>{allSelected?'Gesamtsaldo':'Saldo Auswahl'}</span><strong className={selectedBalance>=0?'good-text':'bad-text'}>{selectedBalance>=0?'+':''}{formatMoney(selectedBalance)}</strong></div></div>
|
||||
<section className="panel dashboard-balances"><div className="section-head"><div><h2>Salden</h2><p>{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.'}</p></div><div className="dashboard-selected-total"><span>{allSelected?'Gesamtsaldo':'Saldo Auswahl'}</span><strong className={selectedBalance>=0?'good-text':'bad-text'}>{selectedBalance>=0?'+':''}{formatMoney(selectedBalance)}</strong></div></div>
|
||||
{visibleAccounts.length===0?<Empty text="Keine Konten ausgewählt."/>:<div className="account-balance-list">{visibleAccounts.map(s=><details className="account-balance-card" key={s.account.id}><summary><div className="account-balance-title"><h3>{s.account.name}</h3><span>monatlicher Fix-Saldo</span></div><div className="account-balance-right"><strong className={`account-balance-value ${s.balance>=0?'good-text':'bad-text'}`}>{s.balance>=0?'+':''}{formatMoney(s.balance)}</strong><span className="accordion-chevron" aria-hidden="true">⌄</span></div></summary><div className="account-balance-details"><div className="mini-grid"><div><span>Eingänge</span><strong className="good-text">+{formatMoney(s.income)}</strong></div><div><span>Ausgänge</span><strong className="bad-text">−{formatMoney(s.expense)}</strong></div>{includeTransfers&&<><div><span>Transfers rein</span><strong className="good-text">+{formatMoney(s.transferIn)}</strong></div><div><span>Transfers raus</span><strong className="bad-text">−{formatMoney(s.transferOut)}</strong></div></>}</div></div></details>)}</div>}
|
||||
</section>
|
||||
|
||||
@@ -500,46 +590,46 @@ function Dashboard({data,totals,onToggle,includeTransfers,onTransfersChange}){
|
||||
</div>;
|
||||
}
|
||||
|
||||
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 <div className="content-stack planning-page">
|
||||
<TransferToggle enabled={includeTransfers} onChange={onTransfersChange}/>
|
||||
<section className="summary-grid planning-summary">
|
||||
<Metric label="Abflussbedarf aller Konten / Monat" value={totalRequired}/>
|
||||
<Metric label="Zusätzliche Zuführung" value={totalFundingNeed} negative={totalFundingNeed>0} positive={totalFundingNeed===0}/>
|
||||
<Metric label="Rücklage / Monat" value={reserves.monthly}/>
|
||||
<Metric label="Periodischer Bedarf / Jahr" value={reserves.annual}/>
|
||||
</section>
|
||||
<section className="panel"><div className="section-head"><div><h2>Benötigt pro Konto</h2><p>{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.'}</p></div></div>{totals.accounts.length===0?<Empty text="Lege zuerst ein Konto an."/>:<div className="funding-grid">{totals.accounts.map(s=><div className="funding-card" key={s.account.id}><div className="funding-head"><strong>{s.account.name}</strong><span className={s.fundingNeed>0?'bad-text':'good-text'}>{s.fundingNeed>0?`${formatMoney(s.fundingNeed)} fehlen`:`${formatMoney(s.surplus)} übrig`}</span></div><div className="funding-numbers"><div><span>Abflussbedarf</span><strong>{formatMoney(s.requiredOutflow)}</strong></div><div><span>Geplante Zuflüsse</span><strong>{formatMoney(s.coveredInflow)}</strong></div><div><span>Zusätzliche Zuführung</span><strong className={s.fundingNeed>0?'bad-text':'good-text'}>{formatMoney(s.fundingNeed)}</strong></div></div></div>)}</div>}</section>
|
||||
<section className="panel reserve-panel"><div className="section-head"><div><h2>Rücklagenübersicht</h2><p>Periodische Ausgaben auf einen gleichmäßigen Monatsbetrag heruntergerechnet.</p></div><div className="reserve-total"><span>{formatMoney(reserves.annual)} / Jahr</span><strong>{formatMoney(reserves.monthly)} / Monat</strong></div></div>{reserves.items.length===0?<Empty text="Keine periodischen Ausgaben vorhanden."/>:<div className="reserve-list">{reserves.items.map(x=><div className="reserve-row" key={x.id}><div><strong>{x.name}</strong><span>{accountName(data,x.accountId)} · {categoryName(data,x.categoryId)} · {intervals[x.interval].label}</span></div><div><span>{formatMoney(x.annualNeed)} / Jahr</span><strong>{formatMoney(x.monthlyReserve)} / Monat</strong></div></div>)}</div>}</section>
|
||||
<div className="info-note"><strong>Liquiditätsplanung:</strong> 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.</div>
|
||||
<section className="summary-grid planning-summary"><Metric label="Echte Kosten / Monat" value={totalRequired} negative/><Metric label="Zusätzliche Zuführung" value={totalFundingNeed} negative={totalFundingNeed>0} positive={totalFundingNeed===0}/><Metric label="Rücklage / Monat" value={reserves.monthly}/><Metric label="Periodischer Bedarf / Jahr" value={reserves.annual}/></section>
|
||||
<section className="panel"><div className="section-head"><div><h2>Benötigt pro Konto</h2><p>Liquiditätsbedarf je Konto: echte Ausgaben plus Transfers raus, gedeckt durch echte Eingänge plus Transfers rein.</p></div></div>{totals.accounts.length===0?<Empty text="Lege zuerst ein Konto an."/>:<div className="funding-grid">{totals.accounts.map(s=><div className="funding-card" key={s.account.id}><div className="funding-head"><strong>{s.account.name}</strong><span className={s.fundingNeed>0?'bad-text':'good-text'}>{s.fundingNeed>0?`${formatMoney(s.fundingNeed)} fehlen`:`${formatMoney(s.surplus)} übrig`}</span></div><div className="funding-numbers"><div><span>Echte Ausgaben</span><strong>{formatMoney(s.expense)}</strong></div><div><span>Transfers raus</span><strong>{formatMoney(s.transferOut)}</strong></div><div><span>Echte Eingänge</span><strong>{formatMoney(s.income)}</strong></div><div><span>Transfers rein</span><strong>{formatMoney(s.transferIn)}</strong></div><div><span>Zusätzliche Zuführung</span><strong className={s.fundingNeed>0?'bad-text':'good-text'}>{formatMoney(s.fundingNeed)}</strong></div></div></div>)}</div>}</section>
|
||||
<SharedFundingOverview data={data}/>
|
||||
<section className="panel reserve-panel"><div className="section-head"><div><h2>Rücklagenübersicht</h2><p>Periodische echte Ausgaben auf einen gleichmäßigen Monatsbetrag heruntergerechnet.</p></div><div className="reserve-total"><span>{formatMoney(reserves.annual)} / Jahr</span><strong>{formatMoney(reserves.monthly)} / Monat</strong></div></div>{reserves.items.length===0?<Empty text="Keine periodischen Ausgaben vorhanden."/>:<div className="reserve-list">{reserves.items.map(x=><div className="reserve-row" key={x.id}><div><strong>{x.name}</strong><span>{accountName(data,x.accountId)} · {categoryName(data,x.categoryId)} · {intervals[x.interval].label}</span></div><div><span>{formatMoney(x.annualNeed)} / Jahr</span><strong>{formatMoney(x.monthlyReserve)} / Monat</strong></div></div>)}</div>}</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
function Metric({label,value,positive,negative,featured}){return <div className={`metric ${featured?'featured':''}`}><span>{label}</span><strong className={positive?'good-text':negative?'bad-text':''}>{formatMoney(value)}</strong></div>}
|
||||
function TextMetric({label,value,hint}){return <div className="metric text-metric"><span>{label}</span><strong>{value}</strong>{hint&&<small>{hint}</small>}</div>}
|
||||
function CountMetric({label,value}){return <div className="metric"><span>{label}</span><strong>{Number(value||0).toLocaleString('de-DE')}</strong></div>}
|
||||
|
||||
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 <div className="person-multi-field">
|
||||
<span>Personen</span>
|
||||
<span>{label}</span>
|
||||
{(persons||[]).length===0?<small className="field-help">Noch keine Personen angelegt.</small>:<div className="person-choice-grid">{persons.map(person=>{const active=selected.has(person.id);return <button key={person.id} type="button" className={`person-choice ${active?'active':''}`} aria-pressed={active} onClick={()=>toggle(person.id)}><span className="checkbox-mark">{active?'✓':''}</span><strong>{person.name}</strong></button>})}</div>}
|
||||
<small className="field-help">Mehrere Personen möglich. In personenbezogenen Auswertungen wird der Betrag gleichmäßig auf alle ausgewählten Personen verteilt.</small>
|
||||
{help&&<small className="field-help">{help}</small>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
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 <div className="content-stack accounts-people-page">
|
||||
<section className="panel"><div className="section-head"><div><h2>Konten</h2><p>Konten dienen nur zur Zuordnung. Es gibt keine gespeicherten Kontostände.</p></div><button className="secondary" onClick={onAdd}>+ Konto</button></div>{data.accounts.length===0?<Empty text="Noch keine Konten vorhanden."/>:<div className="table-wrap"><table><thead><tr><th>Konto</th><th></th></tr></thead><tbody>{data.accounts.map(a=><tr key={a.id}><td><strong>{a.name}</strong></td><td className="actions"><button onClick={()=>onEdit(a)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(a.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>
|
||||
<section className="panel"><div className="section-head"><div><h2>Personen</h2><p>Du kannst beliebig viele Personen hinterlegen. Jede Bewegung kann einer oder mehreren Personen zugeordnet werden, einschließlich Umbuchungen.</p></div><button className="secondary" onClick={onAddPerson}>+ Person</button></div>{unassigned>0&&<div className="person-unassigned-note"><strong>{unassigned} bestehende {unassigned===1?'Bewegung ist':'Bewegungen sind'} noch ohne Person.</strong><span>Diese Altbestände bleiben erhalten und können beim Bearbeiten zugeordnet werden. Neue Bewegungen benötigen mindestens eine Person.</span></div>}{(data.persons||[]).length===0?<Empty text="Noch keine Personen angelegt."/>:<div className="category-manager-list">{data.persons.map(person=><div className="category-manager-row" key={person.id}><div><strong>{person.name}</strong><span>{personUsage(person.id)} {personUsage(person.id)===1?'zugeordnete Bewegung':'zugeordnete Bewegungen'}</span></div><div className="actions"><button onClick={()=>onEditPerson(person)}>Bearbeiten</button><button className="danger" onClick={()=>onDeletePerson(person.id)}>Löschen</button></div></div>)}</div>}</section>
|
||||
<section className="panel"><div className="section-head"><div><h2>Konten</h2><p>Eigentum bestimmt, wem Geld wirtschaftlich gehört. Die Kontofunktion bestimmt, ob eine Umbuchung z. B. Rücklage, Investment oder Bausparen ist.</p></div><button className="secondary" onClick={onAdd}>+ Konto</button></div>{unconfiguredAccounts>0&&<div className="account-config-note"><strong>{unconfiguredAccounts} {unconfiguredAccounts===1?'Konto braucht':'Konten brauchen'} noch eine Eigentumszuordnung.</strong><span>Bestehende Konten wurden absichtlich nicht automatisch geraten. Bitte einmal bearbeiten, Eigentum festlegen und die passende Kontofunktion wählen.</span></div>}{data.accounts.length===0?<Empty text="Noch keine Konten vorhanden."/>:<div className="table-wrap account-manager-table"><table><thead><tr><th>Konto</th><th>Eigentum</th><th>Funktion</th><th></th></tr></thead><tbody>{data.accounts.map(a=><tr key={a.id}><td><strong>{a.name}</strong></td><td><span className={`ownership-badge ${a.ownership||'unassigned'}`}>{accountOwnerships[a.ownership]||accountOwnerships.unassigned}</span><small className="account-owner-copy">{accountOwnerLabel(data,a)}</small></td><td><span className="account-function-badge">{accountFunctionLabel(a)}</span></td><td className="actions"><button onClick={()=>onEdit(a)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(a.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>
|
||||
<section className="panel"><div className="section-head"><div><h2>Personen</h2><p>Personen können Konten besitzen und einzelnen Bewegungen gemeinsam zugeordnet werden.</p></div><button className="secondary" onClick={onAddPerson}>+ Person</button></div>{unassigned>0&&<div className="person-unassigned-note"><strong>{unassigned} bestehende {unassigned===1?'Bewegung ist':'Bewegungen sind'} noch ohne Person.</strong><span>Diese Altbestände bleiben erhalten und können beim Bearbeiten zugeordnet werden. Neue Bewegungen benötigen mindestens eine Person.</span></div>}{(data.persons||[]).length===0?<Empty text="Noch keine Personen angelegt."/>:<div className="category-manager-list">{data.persons.map(person=><div className="category-manager-row" key={person.id}><div><strong>{person.name}</strong><span>{personUsage(person.id)} {personUsage(person.id)===1?'Bewegung':'Bewegungen'} · {ownedAccounts(person.id)} {ownedAccounts(person.id)===1?'Konto':'Konten'}</span></div><div className="actions"><button onClick={()=>onEditPerson(person)}>Bearbeiten</button><button className="danger" onClick={()=>onDeletePerson(person.id)}>Löschen</button></div></div>)}</div>}</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
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 <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Verschiebungen zwischen eigenen Konten. Global saldoneutral.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<>
|
||||
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Personen</th><th>Kategorie</th><th>Von</th><th>Nach</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=><tr key={x.id}><td><strong>{x.name}</strong>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</td><td><span className={`person-badge ${personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.fromAccountId)}</td><td>{accountNameLocal(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className="number">{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>)}</tbody></table></div>
|
||||
<div className="movement-mobile-list">{data.transfers.map(x=><article className="movement-card transfer-card" key={x.id}>
|
||||
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div><strong>{formatMoney(x.amount)}</strong></div>
|
||||
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Transfers werden automatisch anhand von Eigentum und Kontofunktion klassifiziert. Sie beeinflussen Kontoliquidität, sind aber keine Konsumausgaben.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<>
|
||||
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Typ</th><th>Personen</th><th>Kategorie</th><th>Von</th><th>Nach</th><th>Intervall</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=>{const cls=transferClassification(data,x);return <tr key={x.id}><td><strong>{x.name}</strong>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</td><td><span className={`transfer-class-badge ${cls.key}`}>{cls.label}</span></td><td><span className={`person-badge ${personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.fromAccountId)}</td><td>{accountNameLocal(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>})}</tbody></table></div>
|
||||
<div className="movement-mobile-list">{data.transfers.map(x=>{const cls=transferClassification(data,x);return <article className="movement-card transfer-card" key={x.id}>
|
||||
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`transfer-class-badge ${cls.key}`}>{cls.label}</span><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div><strong>{formatMoney(x.amount)}</strong></div>
|
||||
<div className="transfer-route"><div><span>Von</span><strong>{accountNameLocal(x.fromAccountId)}</strong></div><span className="route-arrow">→</span><div><span>Nach</span><strong>{accountNameLocal(x.toAccountId)}</strong></div></div>
|
||||
<div className="movement-card-grid">
|
||||
<div><span>Personen</span><strong>{personLabelLocal(x)}</strong></div>
|
||||
<div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div>
|
||||
<div><span>Fälligkeit</span><strong>{dueText(x)}</strong></div>
|
||||
<div><span>Ø / Monat</span><strong>{formatMoney(monthlyEquivalent(x))}</strong></div>
|
||||
</div>
|
||||
<div className="movement-card-grid"><div><span>Personen</span><strong>{personLabelLocal(x)}</strong></div><div><span>Einordnung</span><strong>{cls.description}</strong></div><div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div><div><span>Ø / Monat</span><strong>{formatMoney(monthlyEquivalent(x))}</strong></div></div>
|
||||
<div className="movement-card-actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></div>
|
||||
</article>)}</div>
|
||||
</article>})}</div>
|
||||
</>}</section>;
|
||||
}
|
||||
|
||||
|
||||
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 <section className="panel transfer-type-panel"><div className="section-head"><div><h2>Transfer- und Finanzierungsarten</h2><p>Diese Beträge sind Kontobewegungen, aber keine Kosten. FixFin leitet die Art automatisch aus Eigentum und Kontofunktion ab.</p></div></div>{stats.rows.length===0?<Empty text="Keine Transfers für diese Auswahl vorhanden."/>:<div className="transfer-type-grid">{stats.rows.map(row=><div className="transfer-type-card" key={row.key}><div><span className={`transfer-class-badge ${row.key}`}>{row.label}</span><small>{row.description}</small></div><strong>{formatMoney(row.amount)} / Monat</strong>{!stats.isAll&&<div className="transfer-type-directions"><span>rein {formatMoney(row.in)}</span><span>raus {formatMoney(row.out)}</span></div>}</div>)}</div>}</section>;
|
||||
}
|
||||
|
||||
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 <section className="panel category-stat-panel"><div className="section-head"><div><h2>{title}</h2><p>{subtitle}</p></div><strong className={`category-total ${tone==='income'?'good-text':tone==='expense'?'bad-text':''}`}>{formatMoney(stats.total)} / Monat</strong></div>
|
||||
@@ -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 <div className="content-stack stats-page">
|
||||
<section className="panel stats-overview-panel"><div className="section-head"><div><h2>Gesamtkennzahlen</h2><p>Die übergeordneten Kennzahlen liegen hier statt auf der Übersicht.</p></div></div><div className="stats-kpi-grid">
|
||||
<Metric label="Gesamtsaldo / Monat" value={totals.balance} positive={totals.balance>=0} negative={totals.balance<0} featured/>
|
||||
<TextMetric label="Fixkostenquote" value={`${kpis.fixedCostRatio.toFixed(1).replace('.',',')} %`} hint="Ausgänge ÷ Eingänge"/>
|
||||
<Metric label="Fixkosten / Jahr" value={kpis.annualExpense} negative/>
|
||||
<Metric label="Rücklage periodisch / Monat" value={kpis.reserveMonthly}/>
|
||||
<TextMetric label="Teuerster Monat" value={kpis.expensive?.name||'–'} hint={kpis.expensive?formatMoney(kpis.expensive.displayExpense):'–'}/>
|
||||
<TextMetric label="Günstigster Monat" value={kpis.cheap?.name||'–'} hint={kpis.cheap?formatMoney(kpis.cheap.displayExpense):'–'}/>
|
||||
</div></section>
|
||||
<section className="panel stats-overview-panel"><div className="section-head"><div><h2>Gesamtkennzahlen</h2><p>Wirtschaftliche Kennzahlen verwenden nur echte Einnahmen und echte Ausgaben. Umbuchungen verändern diese Werte nicht.</p></div></div><div className="stats-kpi-grid"><Metric label="Wirtschaftlicher Saldo / Monat" value={totals.balance} positive={totals.balance>=0} negative={totals.balance<0} featured/><TextMetric label="Fixkostenquote" value={`${kpis.fixedCostRatio.toFixed(1).replace('.',',')} %`} hint="Echte Ausgänge ÷ echte Eingänge"/><Metric label="Echte Kosten / Jahr" value={kpis.annualExpense} negative/><Metric label="Rücklage periodisch / Monat" value={kpis.reserveMonthly}/><TextMetric label="Teuerster Monat" value={kpis.expensive?.name||'–'} hint={kpis.expensive?formatMoney(kpis.expensive.displayExpense):'–'}/><TextMetric label="Günstigster Monat" value={kpis.cheap?.name||'–'} hint={kpis.cheap?formatMoney(kpis.cheap.displayExpense):'–'}/></div></section>
|
||||
<section className="year-account-filter stats-filter"><div className="year-filter-copy"><span>Statistik für</span><strong>{selectedName} · {selectedPerson}</strong></div><div className="stats-filter-controls"><label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Person auswählen</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div></section>
|
||||
<TransferToggle enabled={includeTransfers} onChange={onTransfersChange}/>
|
||||
<section className="summary-grid stats-summary"><Metric label="Eingänge Ø / Monat" value={stats.incomes.total} positive/><Metric label="Ausgänge Ø / Monat" value={stats.expenses.total} negative/><CountMetric label="Kategorien" value={data.categories.length}/><CountMetric label="Ohne Kategorie" value={uncategorized}/></section>
|
||||
<section className="summary-grid stats-summary"><Metric label={personalIncomeLabel} value={stats.incomes.total} positive/><Metric label="Echte Kosten Ø / Monat" value={stats.expenses.total} negative/><CountMetric label="Kategorien" value={data.categories.length}/><CountMetric label="Ohne Kategorie" value={uncategorized}/></section>
|
||||
<PersonFlowOverview stats={personFlows}/>
|
||||
<CategoryDistribution data={data} title="Ausgaben nach Kategorien" subtitle={stats.transfersMerged?'Ausgaben dieses Kontos inklusive ausgehender Transfers. Kategorie anklicken für die Einzelpositionen.':includeTransfers?'Verteilung auf Basis des durchschnittlichen Monatswerts. Transfers werden separat ausgewiesen.':'Verteilung ohne Transfers auf Basis des durchschnittlichen Monatswerts.'} stats={stats.expenses} tone="expense"/>
|
||||
<CategoryDistribution data={data} title="Eingänge nach Kategorien" subtitle={stats.transfersMerged?'Eingänge dieses Kontos inklusive eingehender Transfers. Kategorie anklicken für die Einzelpositionen.':includeTransfers?'Verteilung der regelmäßigen Eingänge. Transfers werden separat ausgewiesen.':'Verteilung der regelmäßigen Eingänge ohne Transfers.'} stats={stats.incomes} tone="income"/>
|
||||
{includeTransfers&&!stats.transfersMerged&&<CategoryDistribution data={data} title="Transfers nach Kategorien" subtitle="Nur bei Alle Konten separat. Bei Einzelkonten fließen Transfers je nach Richtung in Ein- oder Ausgänge ein." stats={stats.transfers} tone="transfer"/>}
|
||||
{accountId==='all'&&<SharedFundingOverview data={data} personId={personId} showTransfers={includeTransfers}/>}
|
||||
{includeTransfers&&<TransferTypeOverview data={data} accountId={accountId} personId={personId}/>}
|
||||
<CategoryDistribution data={data} title="Kosten nach Kategorien" subtitle="Nur echte Ausgaben. Gemeinsam zugeordnete Kosten werden beim Personenfilter gleichmäßig geteilt; Transfers erscheinen hier niemals als Kosten." stats={stats.expenses} tone="expense"/>
|
||||
<CategoryDistribution data={data} title={personId==='all'?'Eingänge nach Kategorien':'Eigene Eingänge nach Kategorien'} subtitle={personId==='all'?'Nur echte Eingänge. Transfers bleiben getrennt.':'Nur Eingänge auf persönlich zugeordneten Konten dieser Person. Gemeinsame Einnahmen wie Kindergeld werden nicht als persönliche Einnahmen gezählt.'} stats={stats.incomes} tone="income"/>
|
||||
{includeTransfers&&<CategoryDistribution data={data} title="Transfer-/Finanzierungsströme nach Kategorien" subtitle="Separat von Kosten und Einnahmen. Dazu gehören interne Umbuchungen, Vermögensaufbau und Gemeinschaftsfinanzierung." stats={stats.transfers} tone="transfer"/>}
|
||||
<section className="panel"><div className="section-head"><div><h2>Kategorien verwalten</h2><p>Kategorien gelten für Eingänge, Ausgänge und Transfers gemeinsam.</p></div><button className="secondary" onClick={onAddCategory}>+ Kategorie</button></div>{data.categories.length===0?<Empty text="Noch keine Kategorien angelegt."/>:<div className="category-manager-list">{data.categories.map(c=><div className="category-manager-row" key={c.id}><div><strong>{c.name}</strong><span>{usage(c.id)} {usage(c.id)===1?'zugeordneter Eintrag':'zugeordnete Einträge'}</span></div><div className="actions"><button onClick={()=>onEditCategory(c)}>Bearbeiten</button><button className="danger" onClick={()=>onDeleteCategory(c.id)}>Löschen</button></div></div>)}</div>}</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
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==='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 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)=><div className="year-detail-row" key={`${x.id}-${x.direction||x.kind}-${i}`}><div className="year-detail-name"><span className={`year-kind ${x.kind}`}>{kindLabel(x)}</span><strong>{x.name}</strong><small>{personListLabel(data,x)}</small></div><span className={`year-detail-amount ${kindTone(x)}`}>{kindSign(x)}{formatMoney(x.forecastAmount??x.amount)}</span></div>;
|
||||
const detailRow=(x,i)=><div className="year-detail-row" key={`${x.id}-${x.direction||x.kind}-${i}`}><div className="year-detail-name"><span className={`year-kind ${x.kind}`}>{kindLabel(x)}</span><strong>{x.name}</strong><small>{x.kind==='coverage'?'Gemeinsame Einnahmen reduzieren die Kostenlast, sind aber kein persönliches Einkommen.':personListLabel(data,x)}</small></div><span className={`year-detail-amount ${kindTone(x)}`}>{kindSign(x)}{formatMoney(x.forecastAmount??x.amount)}</span></div>;
|
||||
|
||||
return <div className="content-stack year-page">
|
||||
<section className="year-account-filter"><div className="year-filter-copy"><span>Jahresansicht für</span><strong>{selectedName} · {selectedPerson} · {year}</strong></div><div className="year-filter-controls"><label><span>Jahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(y=><option key={y} value={y}>{y}</option>)}</select></label><label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Person auswählen</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div></section>
|
||||
|
||||
<TransferToggle enabled={includeTransfers} onChange={onTransfersChange}/>
|
||||
{forecast.isAll&&personId!=='all'&&<div className="info-note"><strong>Personensicht:</strong> Gemeinsame Eingänge werden nicht als persönliche Einnahmen gezählt. Sie reduzieren stattdessen anteilig die gemeinsame Kostenlast dieser Person.</div>}
|
||||
<section className="summary-grid year-summary">
|
||||
<Metric label={labels.yearIncome} value={forecast.yearIncome} positive/>
|
||||
<Metric label={labels.yearExpense} value={forecast.yearExpense} negative/>
|
||||
<Metric label={labels.yearBalance} value={yearBalance} positive={yearBalance>=0} negative={yearBalance<0}/>
|
||||
<CountMetric label="Nicht zugeordnet" value={forecast.unassigned.length}/>
|
||||
<CountMetric label="Nicht zugeordnet" value={forecast.unassigned.filter(x=>includeTransfers||x.kind!=='transfer').length}/>
|
||||
</section>
|
||||
|
||||
<section className="panel burden-profile"><div className="section-head"><div><h2>{forecast.isAll?'Belastungsprofil':'Abflussprofil'}</h2><p>{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.`}</p></div></div><div className="burden-bars">{forecast.rows.map(r=><div className={`burden-row ${r===maxRow?'peak':''}`} key={r.name}><span>{r.name.slice(0,3)}</span><div><i style={{width:`${Math.max(2,r.displayExpense/maxExpense*100)}%`}}/></div><strong>{formatMoney(r.displayExpense)}</strong></div>)}</div></section>
|
||||
<section className="panel burden-profile"><div className="section-head"><div><h2>{personalEconomic?'Kostenlastprofil':forecast.isAll?'Belastungsprofil':'Abflussprofil'}</h2><p>{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.`}</p></div></div><div className="burden-bars">{forecast.rows.map(r=><div className={`burden-row ${r===maxRow?'peak':''}`} key={r.name}><span>{r.name.slice(0,3)}</span><div><i style={{width:`${Math.max(2,r.displayExpense/maxExpense*100)}%`}}/></div><strong>{formatMoney(r.displayExpense)}</strong></div>)}</div></section>
|
||||
|
||||
<section className="panel year-forecast-panel"><div className="section-head"><div><h2>Jahresprognose</h2><p>{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.`}</p></div></div>
|
||||
<section className="panel year-forecast-panel"><div className="section-head"><div><h2>Jahresprognose</h2><p>{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.'}`}</p></div></div>
|
||||
<div className="year-month-list">{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 <details className="year-month-card" key={`${accountId}-${year}-${r.name}`}><summary><div className="year-month-heading"><strong>{r.name}</strong><span>{r.items.length===0?'Keine Fälligkeiten':`${r.items.length} ${r.items.length===1?'Position':'Positionen'}`}</span></div><div className="year-month-basics"><div><span>{labels.income}</span><strong className="good-text">+{formatMoney(r.displayIncome)}</strong></div><div><span>{labels.expense}</span><strong className="bad-text">−{formatMoney(r.displayExpense)}</strong></div><div className="year-month-balance"><span>{labels.balance}</span><strong className={r.balance>=0?'good-text':'bad-text'}>{r.balance>=0?'+':''}{formatMoney(r.balance)}</strong></div></div><span className="accordion-chevron" aria-hidden="true">⌄</span></summary>
|
||||
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 <details className="year-month-card" key={`${accountId}-${year}-${r.name}`}><summary><div className="year-month-heading"><strong>{r.name}</strong><span>{visibleItems.length===0?'Keine sichtbaren Fälligkeiten':`${visibleItems.length} ${visibleItems.length===1?'Position':'Positionen'}`}</span></div><div className="year-month-basics"><div><span>{labels.income}</span><strong className="good-text">+{formatMoney(r.displayIncome)}</strong></div><div><span>{labels.expense}</span><strong className="bad-text">−{formatMoney(r.displayExpense)}</strong></div><div className="year-month-balance"><span>{labels.balance}</span><strong className={r.balance>=0?'good-text':'bad-text'}>{r.balance>=0?'+':''}{formatMoney(r.balance)}</strong></div></div><span className="accordion-chevron" aria-hidden="true">⌄</span></summary>
|
||||
<div className="year-month-details">
|
||||
{r.items.length===0?<div className="year-detail-empty">In diesem Monat sind keine einzelnen Fixpositionen fällig.</div>:forecast.isAll?<>{r.items.map(detailRow)}{includeTransfers&&(r.transferIn>0||r.transferOut>0)&&<div className="year-transfer-note">Interne Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie bleiben vollständig außerhalb von Einnahmen, Ausgaben und Überschuss.</div>}</>:includeTransfers?<>
|
||||
<div className="year-flow-breakdown">
|
||||
<div><span>Echte Eingänge</span><strong className="good-text">+{formatMoney(r.income)}</strong></div>
|
||||
<div><span>Transfers rein</span><strong className="good-text">+{formatMoney(r.transferIn)}</strong></div>
|
||||
<div><span>Echte Ausgaben</span><strong className="bad-text">−{formatMoney(r.expense)}</strong></div>
|
||||
<div><span>Transfers raus</span><strong className="bad-text">−{formatMoney(r.transferOut)}</strong></div>
|
||||
</div>
|
||||
{visibleItems.length===0?<div className="year-detail-empty">In diesem Monat sind keine sichtbaren Fixpositionen fällig. Transferdetails können über den Schalter eingeblendet werden.</div>:forecast.isAll?<>{visibleItems.map(detailRow)}{includeTransfers&&(r.transferIn>0||r.transferOut>0)&&<div className="year-transfer-note">Transfervolumen in diesem Monat: {formatMoney(r.transferOut)}. Es bleibt vollständig außerhalb von Einnahmen, Ausgaben und Überschuss.</div>}</>:<>
|
||||
<div className="year-flow-breakdown"><div><span>Echte Eingänge</span><strong className="good-text">+{formatMoney(r.income)}</strong></div>{includeTransfers&&<div><span>Transfers rein</span><strong className="good-text">+{formatMoney(r.transferIn)}</strong></div>}<div><span>Echte Ausgaben</span><strong className="bad-text">−{formatMoney(r.expense)}</strong></div>{includeTransfers&&<div><span>Transfers raus</span><strong className="bad-text">−{formatMoney(r.transferOut)}</strong></div>}</div>
|
||||
{inflows.length>0&&<div className="year-detail-group"><div className="year-detail-group-title"><span>Zuflüsse</span><strong className="good-text">+{formatMoney(r.displayIncome)}</strong></div>{inflows.map(detailRow)}</div>}
|
||||
{outflows.length>0&&<div className="year-detail-group"><div className="year-detail-group-title"><span>Abflüsse</span><strong className="bad-text">−{formatMoney(r.displayExpense)}</strong></div>{outflows.map(detailRow)}</div>}
|
||||
<div className="year-transfer-note">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.</div>
|
||||
</>:<>{r.items.map(detailRow)}</>}
|
||||
<div className="year-transfer-note">Kontosicht: Der Nettofluss bleibt immer liquiditätsbasiert. Transfers sind keine Kosten, können hier aber separat ein- oder ausgeblendet werden.</div>
|
||||
</>}
|
||||
</div>
|
||||
</details>;
|
||||
})}</div>
|
||||
</section>
|
||||
{forecast.unassigned.length>0&&<section className="panel warning-panel"><div className="section-head"><div><h2>Periodische Positionen ohne Monat</h2><p>Diese Werte sind keinem konkreten Monat der Jahresprognose zugeordnet.</p></div></div><div className="due-items">{forecast.unassigned.map((x,i)=><span className={`due-chip ${x.kind}`} key={`${x.kind}-${x.id}-${x.direction||'neutral'}-${i}`}>{chipLabel(x)}</span>)}</div></section>}
|
||||
{forecast.unassigned.some(x=>includeTransfers||x.kind!=='transfer')&&<section className="panel warning-panel"><div className="section-head"><div><h2>Periodische Positionen ohne Monat</h2><p>Diese Werte sind keinem konkreten Monat der Jahresprognose zugeordnet.</p></div></div><div className="due-items">{forecast.unassigned.filter(x=>includeTransfers||x.kind!=='transfer').map((x,i)=><span className={`due-chip ${x.kind}`} key={`${x.kind}-${x.id}-${x.direction||'neutral'}-${i}`}>{chipLabel(x)}</span>)}</div></section>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
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 <div className="content-stack scenario-page">
|
||||
<TransferToggle enabled={includeTransfers} onChange={onTransfersChange}/>
|
||||
<div className="info-note"><strong>Szenario-Liquidität:</strong> Transfers werden in Kontosalden immer als echte Zu- oder Abflüsse berücksichtigt, bleiben aber wirtschaftlich saldoneutral.</div>
|
||||
<section className="scenario-toolbar"><div><span>Szenario</span>{scenarios.length?<select value={scenario?.id||''} onChange={e=>setSelectedId(e.target.value)}>{scenarios.map(s=><option key={s.id} value={s.id}>{s.name}</option>)}</select>:<strong>Noch keines angelegt</strong>}</div><div className="button-row"><button className="primary" onClick={addScenario}>+ Szenario</button>{scenario&&<><button className="secondary" onClick={renameScenario}>Umbenennen</button><button className="secondary danger-button" onClick={deleteScenario}>Löschen</button></>}</div></section>
|
||||
{!scenario?<section className="panel"><Empty text="Lege ein Szenario an. Es verwendet deinen aktuellen Plan als Basis und ergänzt nur die simulierten Änderungen."/></section>:<>
|
||||
<section className="summary-grid scenario-summary"><Metric label="Aktuell frei / Monat" value={baseTotals.balance} positive={baseTotals.balance>=0} negative={baseTotals.balance<0}/><Metric label="Im Szenario frei / Monat" value={projected.balance} positive={projected.balance>=0} negative={projected.balance<0} featured/><Metric label="Veränderung" value={projected.balance-baseTotals.balance} positive={projected.balance-baseTotals.balance>=0} negative={projected.balance-baseTotals.balance<0}/><CountMetric label="Anpassungen" value={(scenario.adjustments||[]).length}/></section>
|
||||
@@ -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 <div className="content-stack statements-page"><section className="panel"><Empty text="Lege zuerst ein Konto an, um einen Kontoauszug zu erstellen."/></section></div>;
|
||||
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 <div className="content-stack statements-page"><TransferToggle enabled={includeTransfers} onChange={onTransfersChange}/><section className="panel report-export-panel"><div className="section-head"><div><h2>Kontoauszug erstellen</h2><p>Nur die geplanten Bewegungen des gewählten Kontos. Keine Stammdaten, Szenarien oder Systemeinstellungen.</p></div><span className="report-badge">PDF · XLSX</span></div><div className="report-controls"><label><span>Jahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(y=><option key={y} value={y}>{y}</option>)}</select></label><label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Person</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div><div className="report-preview"><div><span>Kontoauszug</span><strong>{account} · {selectedPerson} · {year}</strong></div><p>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.</p></div><div className="button-row report-actions"><a className="primary button-link" href={`/api/report.pdf?${query}`}>PDF herunterladen</a><a className="secondary button-link" href={`/api/report.xlsx?${query}`}>Excel herunterladen</a></div><p className="report-disclaimer">Erstellt aus FixFin-Planungsdaten, kein von einer Bank ausgestellter Kontoauszug.</p></section></div>;
|
||||
return <div className="content-stack statements-page"><TransferToggle enabled={includeTransfers} onChange={onTransfersChange} context="export"/><section className="panel report-export-panel"><div className="section-head"><div><h2>Kontoauszug erstellen</h2><p>Nur die geplanten Bewegungen des gewählten Kontos. Keine Stammdaten, Szenarien oder Systemeinstellungen.</p></div><span className="report-badge">PDF · XLSX</span></div><div className="report-controls"><label><span>Jahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(y=><option key={y} value={y}>{y}</option>)}</select></label><label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Person</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div><div className="report-preview"><div><span>Kontoauszug</span><strong>{account} · {selectedPerson} · {year}</strong></div><p>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.</p></div><div className="button-row report-actions"><a className="primary button-link" href={`/api/report.pdf?${query}`}>PDF herunterladen</a><a className="secondary button-link" href={`/api/report.xlsx?${query}`}>Excel herunterladen</a></div><p className="report-disclaimer">Erstellt aus FixFin-Planungsdaten, kein von einer Bank ausgestellter Kontoauszug.</p></section></div>;
|
||||
}
|
||||
|
||||
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 <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}><div className="modal-head"><div><span className="eyebrow">{dialog.duplicate?'DUPLIZIEREN':editing?'BEARBEITEN':'NEU'}</span><h2>{labels[type]}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>{localError&&<div className="alert">{localError}</div>}<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder={type==='accounts'?'z. B. Girokonto':type==='categories'?'z. B. Wohnen':type==='persons'?'z. B. Michael':'z. B. Kfz-Versicherung'}/></label>
|
||||
{type==='accounts'&&<section className="account-editor-fields"><label><span>Eigentum</span><select value={accountOwnership} onChange={e=>{const value=e.target.value;setAccountOwnership(value);if(value==='personal'&&accountOwnerPersonIds.length>1)setAccountOwnerPersonIds(accountOwnerPersonIds.slice(0,1));}}>{accountOwnership==='unassigned'&&<option value="unassigned" disabled>Bitte zuordnen</option>}<option value="personal">Persönlich</option><option value="shared">Gemeinsam</option></select><small className="field-help">Eigentum entscheidet, ob Eingänge persönlich oder gemeinsam sind und wie Transfers eingeordnet werden.</small></label>{accountOwnership==='personal'?<label><span>Eigentümer</span><select value={accountOwnerPersonIds[0]||''} onChange={e=>setAccountOwnerPersonIds(e.target.value?[e.target.value]:[])}><option value="">Bitte auswählen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label>:<PersonMultiSelect persons={data.persons||[]} selectedIds={accountOwnerPersonIds} onChange={setAccountOwnerPersonIds} label="Gemeinsame Eigentümer" help="Mindestens zwei Personen. Diese Zuordnung beschreibt, wem der gemeinsame Geldtopf gehört."/>}<label><span>Kontofunktion</span><select value={accountFunction} onChange={e=>setAccountFunction(e.target.value)}>{Object.entries(accountFunctions).map(([key,value])=><option key={key} value={key}>{value.label}</option>)}</select><small className="field-help">Damit erkennt FixFin z. B. Rücklagen, Depot- und Bauspar-Umbuchungen automatisch.</small></label></section>}
|
||||
{!simpleType&&<><label><span>Betrag pro Intervall</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span>€</span></div></label><label><span>Intervall</span><select value={interval} onChange={e=>setInterval(e.target.value)}>{Object.entries(intervals).map(([key,x])=><option key={key} value={key}>{x.label}</option>)}</select></label>{interval!=='monthly'&&<label><span>{interval==='yearly'?'Monat der Fälligkeit (optional)':'Erster Fälligkeitsmonat (optional)'}</span><select value={dueMonth} onChange={e=>setDueMonth(e.target.value)}><option value="">Nicht festgelegt</option>{months.map((m,i)=><option key={m} value={i+1}>{m}</option>)}</select><small className="field-help">{interval==='quarterly'?'Ab diesem Monat alle 3 Monate.':interval==='semiannual'?'Ab diesem Monat alle 6 Monate.':'Für die Jahresprognose.'}</small></label>}<label><span>Enddatum / Laufzeit (optional)</span><input type="date" value={endDate} onChange={e=>setEndDate(e.target.value)}/><small className="field-help">Der Monat des Enddatums wird noch vollständig eingeplant.</small></label></>}
|
||||
{(type==='incomes'||type==='expenses'||type==='transfers')&&<><label><span>Kategorie</span><select value={categoryId} onChange={e=>setCategoryId(e.target.value)}><option value="">Ohne Kategorie</option>{data.categories.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}<option value="__new__">+ Neue Kategorie …</option></select></label>{categoryId==='__new__'&&<label className="new-category-field"><span>Neue Kategorie</span><input value={newCategoryName} onChange={e=>setNewCategoryName(e.target.value)} placeholder="z. B. Sparen"/></label>}</>}
|
||||
{(type==='incomes'||type==='expenses'||type==='transfers')&&<>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user