This commit is contained in:
2026-08-21 06:34:32 +02:00
parent 06c6eb867f
commit cc62602131
6 changed files with 149 additions and 26 deletions
+9
View File
@@ -160,3 +160,12 @@ Bei der Rechenprüfung wurden zwei semantische Inkonsistenzen korrigiert: Die Ge
### v3.33 Transferquote zu echten Einnahmen ### v3.33 Transferquote zu echten Einnahmen
Die Transferanalyse startet ohne vorausgewählte Quell- oder Zielkonten. Ausgewählte Konten werden mit einem deutlich sichtbaren Häkchen markiert. Zusätzlich zeigt die Analyse das Verhältnis des ausgewählten Transfervolumens zu den echten Einnahmen der Quellkonten. Die Transferquote wird insgesamt, je Quellkonto und je einzelner Transferposition ausgewiesen. Zielkonten zeigen ergänzend den eingehenden Transferanteil im Verhältnis zu ihren echten Einnahmen. Eingehende Transfers selbst zählen dabei nie als echte Einnahmen; bei 0 EUR echten Einnahmen wird keine Prozentquote berechnet. Die Transferanalyse startet ohne vorausgewählte Quell- oder Zielkonten. Ausgewählte Konten werden mit einem deutlich sichtbaren Häkchen markiert. Zusätzlich zeigt die Analyse das Verhältnis des ausgewählten Transfervolumens zu den echten Einnahmen der Quellkonten. Die Transferquote wird insgesamt, je Quellkonto und je einzelner Transferposition ausgewiesen. Zielkonten zeigen ergänzend den eingehenden Transferanteil im Verhältnis zu ihren echten Einnahmen. Eingehende Transfers selbst zählen dabei nie als echte Einnahmen; bei 0 EUR echten Einnahmen wird keine Prozentquote berechnet.
### v3.34 vollständiges Statistik-Glossar
Die Statistik erklärt jetzt sämtliche fachlichen Kennzahlen und Zwischenwerte direkt in der Oberfläche. Dazu gehören ausdrücklich auch Werte wie **Tatsächliche externe Zuführung**, **Kosten-Finanzierungsbedarf**, **Gemeinsame Deckung**, **Brutto-Kostenanteil**, **Ist-Beitrag**, **Anteil am Bedarf**, **Nettoveränderung Gemeinschaftsvermögen**, **Liquid verfügbar**, **Vermögensaufbau netto** sowie die einkommensbasierte Soll-/Ist-Sicht. Neben den einzelnen Info-Schaltflächen gibt es auf der Statistikseite die zentrale Ansicht **Alle Begriffe & Formeln**. Jede Definition enthält Bedeutung, Formel, enthaltene und ausgeschlossene Bewegungen, Interpretation und ein Zahlenbeispiel. Das Datenmodell und die Berechnungslogik bleiben unverändert.
### v3.35 geplante Betragsänderungen für Transfers
Transfers unterstützen jetzt dieselben zukünftigen Betragsänderungen wie Eingänge und Ausgänge. Der Basisbetrag gilt bis zur ersten Änderung; jede Änderung wird ab ihrem Wirksamkeitsdatum verwendet. Die geänderten Transferbeträge fließen korrekt in Monats-/Jahresplanung, Kontoliquidität, Statistik, Transferanalyse, Gemeinschaftsfinanzierung und PDF/XLSX-Kontoauszüge ein. In der Bewegungsansicht werden geplante Änderungen als Badge und im Detail-Akkordeon angezeigt. Bestehende Transfers werden automatisch mit einem leeren `amountChanges`-Array normalisiert; das JSON-Datenmodell bleibt bei `version: 11`.
+1 -1
View File
@@ -1,4 +1,4 @@
const SHELL_CACHE='fixfin-shell-v3.33'; const SHELL_CACHE='fixfin-shell-v3.35';
const DATA_CACHE='fixfin-data-v1'; const DATA_CACHE='fixfin-data-v1';
const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png']; const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png'];
+4 -3
View File
@@ -35,6 +35,7 @@ function normalizeCategorizedMovement(item={}) {
} }
function normalizeIncome(item={}) { return { ...normalizeCategorizedMovement(item), amountChanges: normalizeAmountChanges(item.amountChanges) }; } function normalizeIncome(item={}) { return { ...normalizeCategorizedMovement(item), amountChanges: normalizeAmountChanges(item.amountChanges) }; }
function normalizeExpense(item={}) { return { ...normalizeCategorizedMovement(item), amountChanges: normalizeAmountChanges(item.amountChanges), pushoverReminder: item.pushoverReminder===true }; } function normalizeExpense(item={}) { return { ...normalizeCategorizedMovement(item), amountChanges: normalizeAmountChanges(item.amountChanges), pushoverReminder: item.pushoverReminder===true }; }
function normalizeTransfer(item={}) { return { ...normalizeCategorizedMovement(item), amountChanges: normalizeAmountChanges(item.amountChanges) }; }
function normalizeScenarioAdjustment(item={}) { function normalizeScenarioAdjustment(item={}) {
const type=validScenarioTypes.has(item.type)?item.type:'expense'; const type=validScenarioTypes.has(item.type)?item.type:'expense';
const base={id:item.id,name:item.name,type,amount:Number(item.amount||0),personIds:normalizePersonIds(item)}; const base={id:item.id,name:item.name,type,amount:Number(item.amount||0),personIds:normalizePersonIds(item)};
@@ -52,7 +53,7 @@ function normalizeData(input){
persons:Array.isArray(input.persons)?input.persons.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, incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeIncome):input.incomes,
expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeExpense):input.expenses, expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeExpense):input.expenses,
transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeCategorizedMovement):input.transfers, transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeTransfer):input.transfers,
scenarios:Array.isArray(input.scenarios)?input.scenarios.map(normalizeScenario):[], scenarios:Array.isArray(input.scenarios)?input.scenarios.map(normalizeScenario):[],
}; };
} }
@@ -88,7 +89,7 @@ function validateData(input){
if(a.ownership==='unassigned'&&a.ownerPersonIds.length)throw new Error(`Nicht zugeordnetes Konto '${a.name}' darf keine Eigentümer enthalten.`); 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.interval!=='once'&&(item.dueMonth===null||item.dueMonth===undefined))throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Fälligkeitsmonat gesetzt sein.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval==='once'&&!item.oneTimeDate)throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Datum gesetzt sein.`);} 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.interval!=='once'&&(item.dueMonth===null||item.dueMonth===undefined))throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Fälligkeitsmonat gesetzt sein.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval==='once'&&!item.oneTimeDate)throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Datum 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');} for(const t of input.transfers){validateMovement(t);validateAmountChanges(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(); const scenarioIds=new Set();
for(const s of input.scenarios){ for(const s of input.scenarios){
if(!s.id||typeof s.id!=='string')throw new Error('Szenario ohne gültige ID.'); if(!s.id||typeof s.id!=='string')throw new Error('Szenario ohne gültige ID.');
@@ -152,7 +153,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 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!==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'))||movements.some(x=>x.interval==='once'&&!Object.hasOwn(x,'oneTimeDate'))||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 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'))||(parsed.transfers||[]).some(x=>!Array.isArray(x.amountChanges))||movements.some(x=>!Object.hasOwn(x,'endDate'))||movements.some(x=>x.interval==='once'&&!Object.hasOwn(x,'oneTimeDate'))||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);} 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);}}); 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); 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);
+2 -2
View File
@@ -57,7 +57,7 @@ export function buildAnnualForecast(data,year,accountId,personId='all',includeTr
if(includeTransfers) for(const item of data.transfers||[]){ if(includeTransfers) for(const item of data.transfers||[]){
const factor=transferFactorForAccount(data,item,personId,accountId); if(!factor||(item.fromAccountId!==accountId&&item.toAccountId!==accountId))continue; const factor=transferFactorForAccount(data,item,personId,accountId); if(!factor||(item.fromAccountId!==accountId&&item.toAccountId!==accountId))continue;
for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){
const amount=Number(item.amount||0)*factor; const amount=effectiveAmount(item,year,m)*factor;
if(item.toAccountId===accountId){rows[m].transferIn+=amount;rows[m].items.push({...item,kind:'transfer',direction:'in',reportAmount:amount});} if(item.toAccountId===accountId){rows[m].transferIn+=amount;rows[m].items.push({...item,kind:'transfer',direction:'in',reportAmount:amount});}
if(item.fromAccountId===accountId){rows[m].transferOut+=amount;rows[m].items.push({...item,kind:'transfer',direction:'out',reportAmount:amount});} if(item.fromAccountId===accountId){rows[m].transferOut+=amount;rows[m].items.push({...item,kind:'transfer',direction:'out',reportAmount:amount});}
} }
@@ -69,7 +69,7 @@ export function buildAnnualForecast(data,year,accountId,personId='all',includeTr
function occurrenceRows(data,year,accountId,personId='all',includeTransfers=true){ function occurrenceRows(data,year,accountId,personId='all',includeTransfers=true){
const result=[]; const result=[];
const push=(item,kind,monthIndex,direction='',factor=1)=>{ const push=(item,kind,monthIndex,direction='',factor=1)=>{
const amount=(kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex))*factor; const amount=effectiveAmount(item,year,monthIndex)*factor;
result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,person:kind==='transfer'?transferPersonLabel(data,item):personLabel(data,item),category:categoryName(data,item.categoryId),amount}); result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,person:kind==='transfer'?transferPersonLabel(data,item):personLabel(data,item),category:categoryName(data,item.categoryId),amount});
}; };
for(const item of data.incomes||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m,'',factor);} for(const item of data.incomes||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m,'',factor);}
+98 -20
View File
@@ -266,7 +266,7 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear()
if (includeTransfers) for (const item of data.transfers.filter(relevantTransfer)) { if (includeTransfers) for (const item of data.transfers.filter(relevantTransfer)) {
const factor=transferPersonFactor(data,item,personId,accountId); const factor=transferPersonFactor(data,item,personId,accountId);
for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) {
const amount = Number(item.amount || 0) * factor; const amount = effectiveAmount(item,year,m) * factor;
if (isAll) { if (isAll) {
rows[m].transferIn += amount; rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'neutral', forecastAmount:amount }); rows[m].transferIn += amount; rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'neutral', forecastAmount:amount });
} else { } else {
@@ -555,6 +555,80 @@ function personFlowStats(data, accountId = 'all', personId = 'all', includeTrans
const STATS_GLOSSARY = {
monthlyAverage:{section:'Grundlagen',title:'Ø / Monat',meaning:'Ein vergleichbarer Monatsdurchschnitt für das gewählte Kalenderjahr. Er ist kein zwingend tatsächlich in jedem Monat gebuchter Betrag.',formula:'Im gewählten Jahr eingeplanter Gesamtbetrag ÷ 12.',includes:'Monatliche, vierteljährliche, halbjährliche, jährliche und einmalige Positionen, jeweils nur soweit sie im gewählten Jahr wirksam sind.',excludes:'Monate außerhalb des gewählten Jahres. Einmalige Positionen aus anderen Jahren.',interpretation:'Der Wert eignet sich zum Vergleichen und zur langfristigen Planung. Für die konkrete Belastung eines bestimmten Monats ist die Jahresplanung maßgeblich.',example:'Eine Jahresversicherung von 1.200 € ergibt 100 € Ø / Monat. Tatsächlich können die gesamten 1.200 € im März fällig sein.'},
annualAmount:{section:'Grundlagen',title:'Betrag / Jahr',meaning:'Der im gewählten Kalenderjahr insgesamt eingeplante Betrag einer Kennzahl oder Position.',formula:'Summe aller im gewählten Jahr wirksamen Fälligkeiten bzw. Monatsbeträge.',includes:'Nur den gewählten Jahreszeitraum, inklusive einmaliger Positionen in diesem Jahr und zeitanteilig wirksamer Betragsänderungen oder Enddaten.',excludes:'Beträge außerhalb des Jahres.',interpretation:'Im Gegensatz zu Ø / Monat zeigt dieser Wert die gesamte Jahreswirkung. Bei einer monatlichen Position ist er typischerweise Monatswert × 12, bei Einmalig exakt der einmalige Betrag.',example:'100 € monatlich ergeben 1.200 € / Jahr. Eine einmalige Ausgabe von 750 € ergibt 750 € / Jahr.'},
percentPoints:{section:'Grundlagen',title:'Prozentpunkte',meaning:'Direkte Differenz zwischen zwei Prozentwerten. Prozentpunkte sind nicht dasselbe wie eine prozentuale Veränderung.',formula:'Ist-% Soll-%.',includes:'Die beiden verglichenen Prozentanteile.',excludes:'Keine relative Prozentänderung des Sollwerts.',interpretation:'+5 Prozentpunkte bedeutet zum Beispiel 55 % statt 50 %, nicht 5 % mehr im relativen Sinn.',example:'Ist 57,1 % und Soll 54,5 % ergeben +2,6 Prozentpunkte.'},
personSplit:{section:'Grundlagen',title:'Personenaufteilung einer Bewegung',meaning:'Wie FixFin eine einzelne Bewegung mehreren zugeordneten Personen zurechnet.',formula:'Betrag ÷ Anzahl der zugeordneten Personen, sofern die jeweilige Statistik auf Bewegungs-Personenzuordnung basiert.',includes:'Ausgaben und in Einzelkonto-Sichten auch Eingänge, wenn mehrere Personen an der Bewegung hinterlegt sind.',excludes:'Persönliches Einkommen in der Gesamt-Personensicht. Dort folgt Einkommen dem Eigentümer des persönlichen Kontos.',interpretation:'Die Aufteilung beschreibt statistische Zuordnung, nicht zwingend wer den Banktransfer technisch ausgeführt hat.',example:'120 € Ausgabe mit drei Personen ergibt 40 € statistischen Anteil je Person.'},
transferDetailsToggle:{section:'Grundlagen',title:'Transferdetails anzeigen',meaning:'Steuert nur, ob Transfer- und Finanzierungsdetails in der Statistik sichtbar sind.',formula:'Keine eigene Berechnung. Die zugrunde liegende Kontoliquidität bleibt identisch.',includes:'Sichtbarkeit von Transferblöcken, Ist-Beiträgen und Transferklassifikationen.',excludes:'Keine Änderung an echten Einnahmen, echten Kosten oder dem Liquiditäts-Nettofluss.',interpretation:'Ausblenden vereinfacht die Anzeige, darf aber keine rechnerisch andere Kontoliquidität erzeugen.',example:'Ein Konto mit 500 € Transfer raus behält denselben Nettofluss, egal ob Transferdetails ein- oder ausgeblendet sind.'},
realIncome:{section:'Grundlagen',title:'Echte Eingänge / Einnahmen',meaning:'Geld, das wirtschaftlich neu in den betrachteten Bereich kommt und als Einnahme angelegt wurde.',formula:'Summe der geplanten Eingangspositionen der Auswahl; in Ø-Sichten jeweils Jahresbetrag ÷ 12.',includes:'Als Eingang angelegte Gehälter, Kindergeld, Renten, sonstige Einnahmen und einmalige Einnahmen, soweit sie zur Auswahl gehören.',excludes:'Transfers zwischen eigenen Konten. Eine Umbuchung vom Girokonto auf ein anderes Konto ist keine neue Einnahme.',interpretation:'Zeigt echte wirtschaftliche Zuflüsse. In einer Einzelkonto-Liquiditätssicht kommen Transfers rein separat hinzu.',example:'3.000 € Gehalt plus 200 € Nebeneinnahme ergeben 3.200 € echte Eingänge. Ein Transfer von 500 € vom eigenen Sparkonto zählt nicht dazu.'},
realExpense:{section:'Grundlagen',title:'Echte Ausgaben / Kosten',meaning:'Geld, das wirtschaftlich verbraucht wird und als Ausgabe angelegt wurde.',formula:'Summe der geplanten Ausgaben der Auswahl; in Ø-Sichten jeweils Jahresbetrag ÷ 12.',includes:'Miete, Strom, Versicherungen, Lebensmittelpauschalen und andere als Ausgabe angelegte Positionen.',excludes:'Transfers zu Rücklage, Depot, Bausparer, Gemeinschaftskonto oder anderen eigenen Konten. Diese verändern Liquidität, sind aber keine Kosten.',interpretation:'Zeigt den tatsächlichen wirtschaftlichen Verbrauch. Deshalb bleibt die Kostenstatistik von reinen Umbuchungen frei.',example:'500 € Kreditrate als Ausgabe zählt als Kosten. 500 € vom Girokonto aufs Depot sind dagegen ein Transfer und keine Kosten.'},
transferIn:{section:'Liquidität',title:'Transfers rein',meaning:'Geld, das durch eine interne Umbuchung auf das betrachtete Konto oder in den betrachteten Bereich gelangt.',formula:'Summe aller Transfers, deren Ziel das betrachtete Konto bzw. der Bereich ist.',includes:'Umbuchungen von anderen eigenen, persönlichen oder gemeinsamen Konten, soweit sie den Filter treffen.',excludes:'Echte Einnahmen. Diese werden separat als Eingänge gezeigt.',interpretation:'Erhöht die Kontoliquidität, aber nicht das wirtschaftliche Einkommen des Haushalts.',example:'500 € vom Sparkonto aufs Girokonto erhöhen Transfers rein um 500 €, nicht die echten Einnahmen.'},
transferOut:{section:'Liquidität',title:'Transfers raus',meaning:'Geld, das durch eine interne Umbuchung das betrachtete Konto oder den betrachteten Bereich verlässt.',formula:'Summe aller Transfers, deren Quelle das betrachtete Konto bzw. der Bereich ist.',includes:'Umbuchungen zu Gemeinschaftskonto, Depot, Rücklage, Bausparen oder anderen Konten.',excludes:'Echte Ausgaben. Diese werden separat als Kosten gezeigt.',interpretation:'Senkt die Liquidität des Quellkontos, ohne automatisch eine wirtschaftliche Ausgabe zu erzeugen.',example:'300 € Giro → Depot senken den Giro-Nettofluss um 300 €, sind aber keine 300 € Kosten.'},
accountNetFlow:{section:'Liquidität',title:'Liquiditäts-Nettofluss',meaning:'Die tatsächliche geplante Veränderung der Liquidität eines ausgewählten Kontos innerhalb des betrachteten Zeitraums.',formula:'Echte Eingänge + Transfers rein echte Ausgaben Transfers raus.',includes:'Alle echten Ein- und Ausgänge sowie alle Transfers, die das ausgewählte Konto berühren.',excludes:'Bewegungen auf anderen Konten, die das ausgewählte Konto nicht berühren.',interpretation:'Positiv bedeutet geplanten Liquiditätszuwachs auf dem Konto, negativ eine geplante Unterdeckung bzw. einen Abflussüberschuss.',example:'3.000 € Eingänge + 200 € Transfer rein 1.500 € Kosten 700 € Transfer raus = +1.000 € Nettofluss.'},
economicBalance:{section:'Grundlagen',title:'Wirtschaftlicher Saldo',meaning:'Was nach echten Einnahmen und echten Kosten wirtschaftlich übrig bleibt, unabhängig davon, auf welche eigenen Konten das Geld verschoben wird.',formula:'Echte Einnahmen echte Kosten.',includes:'Nur wirtschaftliche Einnahmen und Ausgaben.',excludes:'Alle Transfers. Transfers ändern nur den Ort des Geldes, nicht den wirtschaftlichen Gesamtsaldo.',interpretation:'Der Wert zeigt, ob der Plan strukturell Überschuss oder Defizit erzeugt.',example:'4.000 € Einnahmen minus 3.200 € Kosten ergeben +800 € wirtschaftlichen Saldo, auch wenn 600 € davon anschließend aufs Depot überwiesen werden.'},
costRatio:{section:'Grundlagen',title:'Kostenquote',meaning:'Welcher Anteil der echten Einnahmen durch echte Kosten gebunden ist.',formula:'Echte Kosten ÷ echte Einnahmen × 100.',includes:'Nur echte Einnahmen und echte Kosten des gewählten Jahres.',excludes:'Transfers und reine Vermögensverschiebungen.',interpretation:'80 % bedeutet: 80 % der echten Einnahmen werden durch echte Kosten verbraucht. 100 % bedeutet vollständige Bindung, über 100 % ein wirtschaftliches Defizit.',example:'3.200 € Kosten bei 4.000 € Einnahmen ergeben 80 % Kostenquote.'},
periodicReserve:{section:'Grundlagen',title:'Rücklagenbedarf periodische Kosten',meaning:'Welcher durchschnittliche Monatsbetrag zurückgelegt werden müsste, um nicht monatlich fällige echte Kosten über das Jahr zu finanzieren.',formula:'Jahresbetrag aller vierteljährlichen, halbjährlichen und jährlichen Ausgaben ÷ 12.',includes:'Periodische echte Ausgaben mit Intervall vierteljährlich, halbjährlich oder jährlich.',excludes:'Monatliche Ausgaben, einmalige Ausgaben und Transfers.',interpretation:'Der Wert ist eine Spar-/Rücklagenorientierung, nicht die tatsächliche Belastung jedes Monats.',example:'1.200 € Jahresversicherung plus 600 € halbjährliche Gesamtbelastung = 1.800 € pro Jahr, also 150 € Rücklagenbedarf Ø / Monat.'},
expensiveMonth:{section:'Grundlagen',title:'Teuerster Monat',meaning:'Der Monat des gewählten Jahres mit den höchsten tatsächlich in diesem Monat fälligen echten Ausgaben.',formula:'Für jeden Monat werden alle dort fälligen echten Ausgaben summiert; der höchste Monatswert gewinnt.',includes:'Monatliche, konkret fällige periodische und einmalige Ausgaben.',excludes:'Transfers.',interpretation:'Hilft, Liquiditätsspitzen zu erkennen. Er ist nicht mit dem Ø-Monatswert zu verwechseln.',example:'Wenn im März zusätzlich 1.200 € Jahresversicherung fällig sind, kann März trotz gleicher laufender Kosten teuerster Monat werden.'},
cheapMonth:{section:'Grundlagen',title:'Günstigster Monat',meaning:'Der Monat des gewählten Jahres mit den niedrigsten tatsächlich fälligen echten Ausgaben.',formula:'Für jeden Monat werden echte fällige Ausgaben summiert; der niedrigste Monatswert wird angezeigt.',includes:'Alle im jeweiligen Monat fälligen echten Ausgaben.',excludes:'Transfers.',interpretation:'Zeigt den Monat mit der geringsten geplanten Kostenbelastung. Ein Monat ohne Kosten kann 0 € erreichen.',example:'Wenn August nur 2.000 € laufende Kosten hat und keine Sonderfälligkeiten, kann er günstiger als Monate mit Versicherungsfälligkeiten sein.'},
personalIncome:{section:'Persönliche Sicht',title:'Eigene Kontoeingänge / persönliches Einkommen',meaning:'Echte Einnahmen auf persönlichen Konten, die der betreffenden Person als Eigentümer gehören.',formula:'Summe der echten Eingänge auf persönlichen Konten dieser Person, als Jahreswert bzw. Jahreswert ÷ 12.',includes:'Einnahmen auf persönlichen Konten der Person.',excludes:'Gemeinsame Einnahmen auf Gemeinschaftskonten und Transfers.',interpretation:'Dies ist die persönliche Einkommensbasis. Gemeinsames Kindergeld wird beispielsweise nicht künstlich auf Personen verteilt.',example:'3.000 € Gehalt auf dem persönlichen Girokonto zählen vollständig als persönliches Einkommen. 500 € Kindergeld auf dem Gemeinschaftskonto nicht.'},
grossCostShare:{section:'Persönliche Sicht',title:'Brutto-Kostenanteil',meaning:'Der Anteil einer Person an allen echten Kosten, bevor gemeinsame Einnahmen entlastend berücksichtigt werden.',formula:'Jede echte Ausgabe wird entsprechend ihrer Personenzuordnung verteilt. Bei zwei zugeordneten Personen erhält jede 50 %, bei drei jede 1/3.',includes:'Persönliche und gemeinsame echte Ausgaben, soweit die Person der Bewegung zugeordnet ist.',excludes:'Transfers und gemeinsame Einnahmen.',interpretation:'Zeigt die rechnerische Kostenverantwortung vor gemeinsamer Deckung.',example:'Eine gemeinsame Ausgabe von 1.200 € mit zwei Personen belastet jede Person mit 600 € Brutto-Kostenanteil.'},
sharedCoverage:{section:'Persönliche Sicht',title:'Gemeinsame Deckung',meaning:'Der Teil der gemeinsamen Einnahmen, der rechnerisch den gemeinsamen Kostenanteil einer Person entlastet.',formula:'Gemeinsame Einnahmen werden höchstens bis zur Höhe der gemeinsamen Kosten berücksichtigt und proportional zu den jeweiligen Brutto-Kostenanteilen im Gemeinschaftsbereich verteilt.',includes:'Echte Einnahmen auf Gemeinschaftskonten desselben Eigentümerkreises, z. B. Kindergeld.',excludes:'Persönliche Einkommen und Transfers in den Gemeinschaftsbereich.',interpretation:'Die Zahl ist keine persönliche Einnahme. Sie reduziert nur die Kostenlast, die privat finanziert werden muss.',example:'500 € gemeinsame Einnahmen bei 1.200 € gemeinsamen Kosten und 50/50-Kostenanteilen bedeuten 250 € gemeinsame Deckung je Person.'},
netCost:{section:'Persönliche Sicht',title:'Netto-Kostenlast',meaning:'Der echte Kostenanteil einer Person nach Entlastung durch gemeinsame Einnahmen.',formula:'max(0, Brutto-Kostenanteil gemeinsame Deckung).',includes:'Persönlich zugeordnete echte Kosten abzüglich der rechnerischen Deckung aus gemeinsamen Einnahmen.',excludes:'Transfers. Eine Depotüberweisung erhöht die Netto-Kostenlast nicht.',interpretation:'Das ist die wirtschaftliche Kostenbelastung, die aus persönlichen Mitteln getragen werden muss.',example:'1.600 € Brutto-Kostenanteil minus 250 € gemeinsame Deckung ergeben 1.350 € Netto-Kostenlast.'},
economicAvailable:{section:'Persönliche Sicht',title:'Wirtschaftlich verfügbar',meaning:'Was vom persönlichen echten Einkommen nach der persönlichen Netto-Kostenlast wirtschaftlich übrig bleibt.',formula:'Persönliches Einkommen Netto-Kostenlast.',includes:'Persönliche echte Einnahmen und persönliche Netto-Kostenlast.',excludes:'Reine Transfers zwischen eigenen Konten. Sie verändern diesen wirtschaftlichen Wert nicht.',interpretation:'Zeigt den persönlichen strukturellen Überschuss vor der Frage, auf welchem Konto das Geld liegt.',example:'3.000 € persönliches Einkommen minus 1.350 € Netto-Kostenlast ergeben 1.650 € wirtschaftlich verfügbar.'},
liquidAvailable:{section:'Persönliche Sicht',title:'Liquid verfügbar',meaning:'Was auf den persönlichen Zahlungsverkehrskonten einer Person nach allen dort geplanten Kontobewegungen übrig bleibt.',formula:'Echte Eingänge auf persönlichen Nicht-Vermögenskonten echte Ausgaben + Transfers rein Transfers raus.',includes:'Persönliche Giro-/Zahlungsverkehrskonten und alle diese Konten berührenden Transfers.',excludes:'Bestände oder Marktwerte von Depot, Rücklage und Bausparen. FixFin führt keine echten Kontostände.',interpretation:'Dieser Wert kann niedriger als wirtschaftlich verfügbar sein, wenn viel Geld in Depot, Rücklage oder Gemeinschaft verschoben wird.',example:'1.650 € wirtschaftlicher Überschuss, davon 300 € Depot und 350 € Gemeinschaftsfinanzierung, kann zu deutlich niedrigerer liquider Verfügbarkeit führen.'},
liquidTransferIn:{section:'Persönliche Sicht',title:'Liquid-Transfers rein',meaning:'Transfers, die auf persönliche Zahlungsverkehrskonten der Person eingehen.',formula:'Summe der Transfers mit Ziel auf einem persönlichen Nicht-Vermögenskonto der Person.',includes:'Zum Beispiel Rücküberweisungen vom eigenen Sparkonto oder Geld von einer anderen Person auf das persönliche Girokonto.',excludes:'Echte Einnahmen und Transfers direkt in Depot/Rücklage.',interpretation:'Erhöht die verfügbare Liquidität, aber nicht das wirtschaftliche Einkommen.',example:'200 € Rücktransfer vom eigenen Sparkonto aufs Girokonto erhöhen Liquid-Transfers rein um 200 €.'},
liquidTransferOut:{section:'Persönliche Sicht',title:'Liquid-Transfers raus',meaning:'Transfers, die von persönlichen Zahlungsverkehrskonten der Person abgehen.',formula:'Summe der Transfers mit Quelle auf einem persönlichen Nicht-Vermögenskonto der Person.',includes:'Depot-Sparraten, Rücklagenzuführungen, Gemeinschaftsfinanzierung und Transfers an andere Personen.',excludes:'Echte Ausgaben.',interpretation:'Senkt die liquide Verfügbarkeit. Wirtschaftlich kann das Geld weiterhin Vermögen oder Gemeinschaftsvermögen sein.',example:'200 € ins Depot plus 350 € aufs Gemeinschaftskonto ergeben 550 € Liquid-Transfers raus.'},
sharedFunded:{section:'Persönliche Sicht',title:'Gemeinschaft finanziert',meaning:'Der tatsächliche persönliche Finanzierungsbeitrag einer Person in einen Gemeinschaftsbereich.',formula:'Summe der Transfers von persönlichen Konten dieser Person auf Konten des Gemeinschaftsbereichs.',includes:'Persönlich → gemeinsam, zugerechnet über den Eigentümer des Quellkontos.',excludes:'Gemeinsame Einnahmen, interne gemeinsame Umbuchungen und Transfers aus nicht persönlichen Quellen.',interpretation:'Zeigt, wie viel die Person tatsächlich in die Gemeinschaft eingezahlt hat. Das kann vom Soll abweichen.',example:'350 € vom persönlichen Girokonto aufs Gemeinschaftskonto ergeben 350 € Gemeinschaft finanziert.'},
assetBuild:{section:'Persönliche Sicht',title:'Vermögensaufbau netto',meaning:'Nettozuführung vom persönlichen Zahlungsverkehr in persönliche Vermögenskonten desselben Eigentümers.',formula:'Zuführungen Zahlungsverkehr → Rücklage/Depot/Bausparen minus Entnahmen Rücklage/Depot/Bausparen → Zahlungsverkehr. Umschichtungen zwischen Vermögenskonten sind 0.',includes:'Transfers innerhalb desselben persönlichen Eigentumsbereichs zwischen Zahlungsverkehr und Vermögenskonten.',excludes:'Kursgewinne, Zinsen, Depotwertentwicklung, Transfers in Gemeinschaftsbereiche und Transfers zu anderen Personen.',interpretation:'Zeigt nur den planmäßigen Geldfluss in persönliches Vermögen, nicht die Rendite des Vermögens.',example:'300 € Giro → Depot und 50 € Depot → Giro ergeben +250 € Vermögensaufbau netto.'},
sharedNetChange:{section:'Gemeinschaftsfinanzierung',title:'Nettoveränderung Gemeinschaftsvermögen',meaning:'Wie sich der gemeinsame Geldtopf durch externe wirtschaftliche Zu- und Abflüsse im Durchschnitt verändert.',formula:'Gemeinsame Einnahmen + tatsächliche externe Zuführungen gemeinsame Kosten externe Entnahmen.',includes:'Echte Einnahmen auf Gemeinschaftskonten, Transfers von außerhalb in den Gemeinschaftsbereich, echte gemeinsame Kosten und Transfers aus dem Gemeinschaftsbereich nach außen.',excludes:'Interne Umbuchungen zwischen Konten desselben Gemeinschaftsbereichs.',interpretation:'Positiv bedeutet, dass gemeinsames Vermögen aufgebaut wird. Negativ bedeutet, dass der Gemeinschaftsbereich netto Mittel verliert.',example:'500 € gemeinsame Einnahmen + 700 € externe Zuführung 1.000 € Kosten 100 € Entnahme = +100 € Nettoveränderung.'},
sharedIncome:{section:'Gemeinschaftsfinanzierung',title:'Gemeinsame Einnahmen',meaning:'Echte Einnahmen, die direkt auf Konten eines Gemeinschaftsbereichs eingehen.',formula:'Summe der echten Eingangspositionen auf allen Gemeinschaftskonten mit demselben Eigentümerkreis.',includes:'Zum Beispiel Kindergeld oder andere echte gemeinsame Einnahmen.',excludes:'Persönliche Überweisungen aufs Gemeinschaftskonto. Diese sind externe Zuführungen, keine gemeinsamen Einnahmen.',interpretation:'Diese Einnahmen gehören dem gemeinsamen Topf und reduzieren den von Personen zu finanzierenden Kostenbedarf.',example:'500 € Kindergeld auf dem Gemeinschaftskonto = 500 € gemeinsame Einnahmen.'},
sharedCost:{section:'Gemeinschaftsfinanzierung',title:'Gemeinsame Kosten',meaning:'Echte Ausgaben, die von Konten des Gemeinschaftsbereichs bezahlt werden.',formula:'Summe aller echten Ausgaben auf den Gemeinschaftskonten desselben Eigentümerkreises.',includes:'Kredit, Energie, Versicherungen oder andere als Ausgabe angelegte Positionen auf gemeinsamen Konten.',excludes:'Transfers innerhalb des Gemeinschaftsbereichs und Transfers auf gemeinsame Rücklagenkonten.',interpretation:'Zeigt den echten wirtschaftlichen Verbrauch des gemeinsamen Bereichs.',example:'900 € Kredit plus 300 € Energie ergeben 1.200 € gemeinsame Kosten.'},
sharedNeed:{section:'Gemeinschaftsfinanzierung',title:'Kosten-Finanzierungsbedarf',meaning:'Der Teil der gemeinsamen Kosten, der nach echten gemeinsamen Einnahmen noch finanziert werden muss.',formula:'max(0, gemeinsame Kosten gemeinsame Einnahmen).',includes:'Gemeinsame Kosten und echte gemeinsame Einnahmen.',excludes:'Bereits tatsächlich geleistete persönliche Transfers. Diese werden erst beim Ist-Beitrag verglichen.',interpretation:'Das ist der Soll-Bedarf des Gemeinschaftsbereichs, nicht zwingend der tatsächliche Transferbetrag und auch nicht der Kontostand.',example:'1.200 € gemeinsame Kosten minus 500 € gemeinsame Einnahmen ergeben 700 € Kosten-Finanzierungsbedarf.'},
externalFunding:{section:'Gemeinschaftsfinanzierung',title:'Tatsächliche externe Zuführung',meaning:'Alle tatsächlichen Transfers, die von außerhalb des betrachteten Gemeinschaftsbereichs auf eines seiner Konten eingehen.',formula:'Summe aller Transfers mit Zielkonto im Gemeinschaftsbereich und Quellkonto außerhalb dieses Eigentumsbereichs.',includes:'Persönlich → gemeinsam, Transfers aus einem anderen Gemeinschaftsbereich und sonstige externe Kontotransfers, sofern Quelle außerhalb der Gruppe liegt.',excludes:'Echte gemeinsame Einnahmen und interne Umbuchungen zwischen Konten desselben Gemeinschaftsbereichs.',interpretation:'Die Zahl zeigt, wie viel Geld tatsächlich von außen in den gemeinsamen Topf verschoben wurde. Sie kann höher, niedriger oder gleich dem Kosten-Finanzierungsbedarf sein. Sie ist nicht automatisch identisch mit der Summe der persönlichen Ist-Beiträge, weil auch Zuführungen aus anderen nicht-persönlichen Bereichen enthalten sein können.',example:'350 € von Person A + 350 € von Person B + 100 € aus einem anderen Gemeinschaftsbereich = 800 € tatsächliche externe Zuführung. Persönliche Ist-Beiträge wären dabei nur 700 €.'},
internalSharedTransfer:{section:'Gemeinschaftsfinanzierung',title:'Interne Umbuchungen',meaning:'Transfers zwischen Konten desselben Gemeinschaftsbereichs, also mit identischem gemeinsamen Eigentümerkreis.',formula:'Summe der Transfers, bei denen Quell- und Zielkonto beide zum selben Gemeinschaftsbereich gehören.',includes:'Zum Beispiel Gemeinschaftsgiro → gemeinsame Haus-Rücklage.',excludes:'Transfers von persönlichen Konten in die Gemeinschaft oder aus der Gemeinschaft heraus.',interpretation:'Verändert nur die Verteilung des gemeinsamen Geldes. Es entstehen weder Kosten noch externe Finanzierung.',example:'300 € Gemeinschaftsgiro → gemeinsame Rücklage sind 300 € interne Umbuchung, aber 0 € zusätzliche gemeinsame Kosten.'},
externalWithdrawal:{section:'Gemeinschaftsfinanzierung',title:'Externe Entnahmen',meaning:'Transfers, die Geld aus dem Gemeinschaftsbereich auf ein Konto außerhalb dieses Bereichs verschieben.',formula:'Summe aller Transfers mit Quelle im Gemeinschaftsbereich und Ziel außerhalb des Bereichs.',includes:'Gemeinsam → persönlich oder gemeinsam → anderer Eigentumsbereich.',excludes:'Echte gemeinsame Ausgaben und interne gemeinsame Umbuchungen.',interpretation:'Senkt das Gemeinschaftsvermögen, ist aber nicht automatisch eine wirtschaftliche Ausgabe.',example:'100 € vom Gemeinschaftskonto auf ein persönliches Konto = 100 € externe Entnahme.'},
costShare:{section:'Gemeinschaftsfinanzierung',title:'Kostenanteil',meaning:'Welcher Anteil der gemeinsamen echten Kosten einer Person aufgrund der Personenzuordnung der Ausgaben zugerechnet wird.',formula:'Persönlicher Bruttoanteil an gemeinsamen Kosten ÷ gesamte zugeordnete gemeinsame Kosten × 100.',includes:'Nur gemeinsame echte Ausgaben und deren Personenzuordnung.',excludes:'Persönliche Einkommen und tatsächliche Transfers.',interpretation:'Zeigt die Kostenverantwortung, nicht wer tatsächlich bezahlt hat.',example:'Bei 1.200 € gemeinsamen Kosten und 600 € zugeordnetem Anteil beträgt der Kostenanteil 50 %.'},
targetFunding:{section:'Gemeinschaftsfinanzierung',title:'Soll-Finanzierung',meaning:'Wie viel des verbleibenden Kosten-Finanzierungsbedarfs eine Person gemäß ihrem Kostenanteil rechnerisch übernehmen sollte.',formula:'Kosten-Finanzierungsbedarf × Kostenanteil.',includes:'Den nach gemeinsamen Einnahmen verbleibenden Bedarf und den Kostenanteil der Person.',excludes:'Tatsächliche Überweisungen. Diese stehen als Ist-Beitrag daneben.',interpretation:'Ein Vergleichswert für die kostenbasierte Finanzierung, keine automatisch erzeugte Zahlung. Der daneben gezeigte Soll-% ist Soll-Finanzierung ÷ gesamter Kosten-Finanzierungsbedarf × 100 und entspricht bei dieser Verteilung dem Kostenanteil.',example:'700 € Bedarf × 50 % Kostenanteil = 350 € Soll-Finanzierung. 350 € ÷ 700 € = 50 % Soll-Finanzierungsanteil.'},
actualContribution:{section:'Gemeinschaftsfinanzierung',title:'Ist-Beitrag',meaning:'Wie viel eine Person tatsächlich aus ihren persönlichen Konten in den Gemeinschaftsbereich überwiesen hat.',formula:'Summe der persönlichen → gemeinsamen Transfers, zugerechnet nach Eigentümer des persönlichen Quellkontos.',includes:'Nur Transfers von persönlichen Quellkonten in den betrachteten Gemeinschaftsbereich.',excludes:'Gemeinsame Einnahmen, Transfers aus anderen Gemeinschaften und interne Umbuchungen.',interpretation:'Zeigt die tatsächliche persönliche Finanzierung. Der Prozentwert daneben zeigt den Anteil dieser Person an allen persönlichen Ist-Beiträgen.',example:'Person A überweist 400 €, Person B 300 €. A hat 400 € Ist-Beitrag und 57,1 % der persönlichen Ist-Beiträge.'},
needCoverage:{section:'Gemeinschaftsfinanzierung',title:'Anteil am Bedarf',meaning:'Welchen Anteil des gesamten verbleibenden Kosten-Finanzierungsbedarfs die tatsächliche persönliche Einzahlung einer Person deckt.',formula:'Ist-Beitrag der Person ÷ Kosten-Finanzierungsbedarf × 100.',includes:'Tatsächlichen persönlichen Ist-Beitrag und den verbleibenden gemeinsamen Bedarf.',excludes:'Gemeinsame Einnahmen, da diese bereits vor Ermittlung des Bedarfs abgezogen wurden.',interpretation:'50 % bedeutet: Diese Person deckt mit ihrer tatsächlichen Zahlung die Hälfte des noch privat zu finanzierenden Bedarfs.',example:'350 € Ist-Beitrag bei 700 € Bedarf = 50 % Anteil am Bedarf.'},
fundingDifference:{section:'Gemeinschaftsfinanzierung',title:'Differenz Ist Soll',meaning:'Abweichung zwischen tatsächlich gezahltem persönlichem Beitrag und kostenbasierter Soll-Finanzierung.',formula:'Ist-Beitrag Soll-Finanzierung.',includes:'Nur die beiden direkt verglichenen Werte Ist und Soll.',excludes:'Gemeinsame Einnahmen sind bereits im Bedarf berücksichtigt und werden nicht nochmals addiert.',interpretation:'Positiv bedeutet mehr gezahlt als Soll, negativ weniger, 0 entspricht dem kostenbasierten Soll.',example:'400 € Ist minus 350 € Soll = +50 € Differenz.'},
householdBurden:{section:'Einkommensvergleich',title:'Belastungsquote Haushalt',meaning:'Welcher Anteil der gesamten persönlichen Einkommen der Eigentümer nötig wäre, um den nach gemeinsamen Einnahmen verbleibenden Gemeinschaftsbedarf zu decken.',formula:'Verbleibender Bedarf ÷ Summe der persönlichen Einkommen × 100.',includes:'Persönliche Einkommen der Gemeinschaftseigentümer und den verbleibenden Bedarf.',excludes:'Gemeinsame Einnahmen als persönliches Einkommen. Diese wurden bereits zur Bedarfsreduktion verwendet.',interpretation:'Die Quote beschreibt die relative Haushaltsbelastung. Bei proportionaler Einkommensverteilung entspricht sie auch dem relativen Einkommensanteil, den jede Person für die Gemeinschaft aufbringen müsste.',example:'700 € Bedarf bei 5.500 € persönlichen Einkommen = 12,7 % Belastungsquote.'},
sharedIncomeCoverage:{section:'Einkommensvergleich',title:'Deckung gemeinsame Einnahmen',meaning:'Der Teil der gemeinsamen Kosten, der direkt durch echte gemeinsame Einnahmen finanziert wird.',formula:'min(gemeinsame Einnahmen, gemeinsame Kosten).',includes:'Echte gemeinsame Einnahmen bis maximal zur Höhe der gemeinsamen Kosten.',excludes:'Persönliche Transfers und Einnahmenüberschüsse oberhalb der Kosten.',interpretation:'Diese Deckung muss nicht aus persönlichen Einkommen finanziert werden.',example:'500 € gemeinsame Einnahmen bei 1.200 € Kosten decken 500 €; verbleibender Bedarf 700 €.'},
remainingNeed:{section:'Einkommensvergleich',title:'Verbleibender Bedarf',meaning:'Gemeinsame Kosten, die nach Nutzung gemeinsamer Einnahmen noch durch persönliche oder andere externe Finanzierung gedeckt werden müssen.',formula:'max(0, gemeinsame Kosten gemeinsame Einnahmen).',includes:'Gemeinsame Kosten und gemeinsame Einnahmen.',excludes:'Die tatsächlichen persönlichen Beiträge. Diese werden anschließend mit dem Soll verglichen.',interpretation:'Ist der Wert 0 €, reichen gemeinsame Einnahmen aus, um die gemeinsamen Kosten vollständig zu decken.',example:'1.200 € Kosten 500 € gemeinsame Einnahmen = 700 € verbleibender Bedarf.'},
personalIncomeTotal:{section:'Einkommensvergleich',title:'Persönliche Einkommen gesamt',meaning:'Summe der echten persönlichen Einkommen aller Eigentümer des betrachteten Gemeinschaftsbereichs.',formula:'Summe der echten Eingänge auf persönlichen Konten der Eigentümer.',includes:'Persönliche echte Einnahmen aller Gemeinschaftseigentümer.',excludes:'Gemeinsame Einnahmen und Transfers.',interpretation:'Dient als Basis für die einkommensproportionale Soll-Verteilung.',example:'3.000 € + 2.500 € persönliche Einkommen = 5.500 € persönliche Einkommen gesamt.'},
incomeShare:{section:'Einkommensvergleich',title:'Einkommensanteil / Soll-%',meaning:'Anteil einer Person an der Summe der persönlichen Einkommen der Gemeinschaftseigentümer.',formula:'Persönliches Einkommen der Person ÷ persönliche Einkommen gesamt × 100.',includes:'Nur persönliche echte Einkommen.',excludes:'Gemeinsame Einnahmen und Transfers.',interpretation:'Dieser Prozentsatz wird in der einkommensbasierten Vergleichssicht als Soll-% für den verbleibenden Bedarf verwendet.',example:'3.000 € von 5.500 € = 54,5 % Einkommensanteil und damit 54,5 % Soll-%.'},
incomeTarget:{section:'Einkommensvergleich',title:'Soll nach Einkommen',meaning:'Hypothetischer persönlicher Finanzierungsbeitrag, wenn der verbleibende Gemeinschaftsbedarf proportional zu den persönlichen Einkommen verteilt würde.',formula:'Verbleibender Bedarf × Einkommensanteil.',includes:'Verbleibenden Bedarf und persönlichen Einkommensanteil.',excludes:'Tatsächliche Zahlungen. Es ist eine Vergleichsrechnung.',interpretation:'Dient als Fairnessvergleich zum Ist-Beitrag und verändert keine Buchung.',example:'700 € Bedarf × 54,5 % = 381,82 € Soll nach Einkommen.'},
incomeBurden:{section:'Einkommensvergleich',title:'Belastung vom Einkommen',meaning:'Welcher Anteil des persönlichen Einkommens durch das einkommensbasierte Soll gebunden wäre.',formula:'Soll nach Einkommen ÷ persönliches Einkommen × 100.',includes:'Persönliches Einkommen und einkommensbasiertes Soll.',excludes:'Andere persönliche Kosten außerhalb dieser Vergleichsrechnung.',interpretation:'Bei streng proportionaler Verteilung ist diese Quote für alle Personen mit Einkommen gleich der Haushalts-Belastungsquote.',example:'381,82 € Soll bei 3.000 € Einkommen = 12,7 % Belastung.'},
actualContributionShare:{section:'Einkommensvergleich',title:'Ist-Beitrag / Ist-%',meaning:'Tatsächlicher persönlicher Beitrag und dessen Anteil an allen persönlichen Einzahlungen in den Gemeinschaftsbereich.',formula:'Ist-% = Ist-Beitrag der Person ÷ Summe aller persönlichen Ist-Beiträge × 100.',includes:'Persönliche → gemeinsame Transfers.',excludes:'Gemeinsame Einnahmen und externe Zuführungen aus nicht persönlichen Bereichen.',interpretation:'Zeigt, wie die tatsächliche private Finanzierung zwischen den Personen verteilt ist.',example:'400 € von insgesamt 700 € persönlichen Einzahlungen = 57,1 % Ist-%.'},
incomeDeviation:{section:'Einkommensvergleich',title:'Abweichung Soll / Ist',meaning:'Vergleicht den tatsächlichen persönlichen Beitrag mit dem einkommensbasierten Soll.',formula:'Betragsabweichung = Ist-Beitrag Soll nach Einkommen. Prozentpunktabweichung = Ist-% Soll-% (Einkommensanteil).',includes:'Einkommensbasiertes Soll und tatsächlichen persönlichen Beitrag.',excludes:'Kostenbasierte Soll-Finanzierung aus dem anderen Gemeinschaftsblock.',interpretation:'Positiv bedeutet über dem einkommensbasierten Soll, negativ darunter.',example:'400 € Ist 381,82 € Soll = +18,18 €. 57,1 % Ist 54,5 % Soll = +2,6 Prozentpunkte.'},
categoryShare:{section:'Kategorien',title:'Kategorieanteil %',meaning:'Wie groß der Anteil einer Kategorie an der Gesamtsumme des jeweiligen Statistikblocks ist.',formula:'Ø-Monatswert der Kategorie ÷ Ø-Monats-Gesamtsumme des Blocks × 100.',includes:'Nur Positionen, die durch Konto-, Personen- und Typfilter in diesem Block enthalten sind.',excludes:'Positionen außerhalb des Blocks. Bei Kosten sind Transfers ausgeschlossen, bei Transfers sind echte Kosten ausgeschlossen.',interpretation:'Die Prozentwerte sind nur innerhalb desselben Blocks vergleichbar.',example:'600 € Kategorie bei 2.400 € Gesamtkosten = 25 % Kategorieanteil.'},
uncategorized:{section:'Kategorien',title:'Ohne Kategorie',meaning:'Anzahl echter Eingangs- und Ausgangspositionen der aktuellen Auswahl, denen keine Kategorie zugewiesen ist.',formula:'Anzahl passender Einnahmen und Ausgaben mit leerer categoryId.',includes:'Echte Einnahmen und echte Ausgaben der aktuellen Auswahl.',excludes:'Transfers, damit der Wert unabhängig vom Transferdetail-Schalter bleibt.',interpretation:'Ein Datenqualitätsindikator. Je niedriger, desto vollständiger ist die Kategorisierung.',example:'2 Ausgaben und 1 Eingang ohne Kategorie ergeben den Wert 3.'},
transferType:{section:'Transfers',title:'Transfer- und Finanzierungsart',meaning:'Automatische fachliche Einordnung eines Transfers anhand von Eigentum und Kontofunktion von Quell- und Zielkonto.',formula:'Regelbasiert, z. B. persönlich → gemeinsam = Gemeinschaftsfinanzierung; Giro → eigenes Depot = Zuführung Investment; gleiche Eigentumsdomäne = interne Umbuchung oder Vermögensverschiebung.',includes:'Alle Transfers der aktuellen Auswahl.',excludes:'Echte Einnahmen und Ausgaben.',interpretation:'Die Klassifikation erklärt den Zweck des Geldflusses, ohne ihn als Kosten oder Einnahme umzudeuten.',example:'Böhmi Giro → Gemeinschaftskonto wird als Gemeinschaftsfinanzierung klassifiziert; Gemeinschaftsgiro → gemeinsame Rücklage als Zuführung gemeinsame Rücklage.'}
};
function StatsTermExplanation({definition}){
return <div className="stats-term-explanation"><p><strong>Was bedeutet die Zahl?</strong><br/>{definition.meaning}</p><p><strong>Berechnung</strong><br/>{definition.formula}</p><p><strong>Enthalten</strong><br/>{definition.includes}</p><p><strong>Nicht enthalten</strong><br/>{definition.excludes}</p><p><strong>Aussage / Interpretation</strong><br/>{definition.interpretation}</p><p className="stats-example"><strong>Beispiel</strong><br/>{definition.example}</p></div>;
}
function StatsTerm({term,label}){
const definition=STATS_GLOSSARY[term];
if(!definition)return <span>{label||term}</span>;
return <span className="stat-term-label"><span>{label||definition.title}</span><StatsInfoButton title={definition.title}><StatsTermExplanation definition={definition}/></StatsInfoButton></span>;
}
function statTermKeyForLabel(label=''){
const value=String(label).replace(/^[+=\-\s]+/,'').toLocaleLowerCase('de-DE');
const pairs=[
['wirtschaftlicher saldo','economicBalance'],['wirtschaftlich verfügbar','economicAvailable'],['liquiditäts-nettofluss','accountNetFlow'],['nettofluss','accountNetFlow'],['kostenquote','costRatio'],['rücklagenbedarf periodische kosten','periodicReserve'],['teuerster monat','expensiveMonth'],['günstigster monat','cheapMonth'],['ohne kategorie','uncategorized'],['echte eingänge','realIncome'],['eigene kontoeingänge','personalIncome'],['echte ausgaben','realExpense'],['echte kosten','realExpense'],['transfers rein','transferIn'],['transfers raus','transferOut'],['brutto-kostenanteil','grossCostShare'],['gemeinsame deckung','sharedCoverage'],['netto-kostenlast','netCost'],['soll gemeinschaftsfinanzierung','targetFunding'],['ist gemeinschaftsfinanzierung','actualContribution'],['vermögensaufbau netto','assetBuild'],['kosten-finanzierungsbedarf','sharedNeed'],['gemeinsame einnahmen','sharedIncome'],['gemeinsame kosten','sharedCost'],['tatsächliche externe zuführung','externalFunding'],['kostenanteil','costShare'],['soll-finanzierung','targetFunding'],['ist-beitrag','actualContribution'],['anteil am bedarf','needCoverage'],['differenz','fundingDifference'],['belastungsquote','householdBurden'],['verbleibender finanzierungsbedarf','remainingNeed'],['verbleibender bedarf','remainingNeed'],['persönliche einkommen','personalIncomeTotal'],['einkommensanteil','incomeShare'],['soll nach einkommen','incomeTarget'],['belastung vom einkommen','incomeBurden'],['abweichung soll / ist','incomeDeviation']
];
return pairs.find(([needle])=>value.includes(needle))?.[1]||null;
}
function StatsGlossaryButton(){
const [open,setOpen]=useState(false);
const groups=[...new Set(Object.values(STATS_GLOSSARY).map(x=>x.section))];
return <><button type="button" className="secondary stats-glossary-button" onClick={()=>setOpen(true)}> Alle Begriffe & Formeln</button>{open&&<StatsHelpModal title="Alle Statistikbegriffe erklärt" onClose={()=>setOpen(false)}><p>Hier sind sämtliche fachlichen Kennzahlen der Statistik erklärt. Jede Erklärung nennt Bedeutung, Formel, Ein- und Ausschlüsse, Interpretation und ein Beispiel.</p><div className="stats-glossary">{groups.map(group=><section key={group}><h3>{group}</h3><div className="stats-glossary-list">{Object.entries(STATS_GLOSSARY).filter(([,def])=>def.section===group).map(([key,def])=><details key={key}><summary>{def.title}<span></span></summary><StatsTermExplanation definition={def}/></details>)}</div></section>)}</div></StatsHelpModal>}</>;
}
function StatsHelpModal({title,onClose,children}){ function StatsHelpModal({title,onClose,children}){
useModalScrollLock(); useModalScrollLock();
return <div className="modal-backdrop stats-help-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><div className="modal stats-help-modal" role="dialog" aria-modal="true" aria-label={title}><div className="modal-head"><div><span className="eyebrow">STATISTIK ERKLÄRT</span><h2>{title}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div><div className="stats-help-content">{children}</div><div className="modal-actions"><button type="button" className="primary" onClick={onClose}>Verstanden</button></div></div></div>; return <div className="modal-backdrop stats-help-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><div className="modal stats-help-modal" role="dialog" aria-modal="true" aria-label={title}><div className="modal-head"><div><span className="eyebrow">STATISTIK ERKLÄRT</span><h2>{title}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div><div className="stats-help-content">{children}</div><div className="modal-actions"><button type="button" className="primary" onClick={onClose}>Verstanden</button></div></div></div>;
@@ -576,19 +650,22 @@ function StatItemList({data,items,empty='Keine beitragenden Positionen.',amountF
})}</div>; })}</div>;
} }
function StatFormulaRows({rows}){ function StatFormulaRows({rows}){
return <div className="stat-formula-rows">{rows.map((row,index)=><div key={`${row.label}-${index}`}><span>{row.label}</span><strong className={row.tone==='good'?'good-text':row.tone==='bad'?'bad-text':''}>{typeof row.value==='number'?formatMoney(row.value):row.value}</strong>{row.note&&<small>{row.note}</small>}</div>)}</div>; return <div className="stat-formula-rows">{rows.map((row,index)=>{const term=row.term||statTermKeyForLabel(row.label);return <div key={`${row.label}-${index}`}>{term?<StatsTerm term={term} label={row.label}/>:<span>{row.label}</span>}<strong className={row.tone==='good'?'good-text':row.tone==='bad'?'bad-text':''}>{typeof row.value==='number'?formatMoney(row.value):row.value}</strong>{row.note&&<small>{row.note}</small>}</div>})}</div>;
} }
function StatMetricCard({label,value,displayValue,tone,featured=false,hint,helpTitle,help,children}){ function StatMetricCard({label,value,displayValue,tone,featured=false,hint,helpTitle,help,children}){
return <div className={`metric stat-explain-metric ${featured?'featured':''}`}><div className="stat-metric-label"><span>{label}</span>{help&&<StatsInfoButton title={helpTitle||label}>{help}</StatsInfoButton>}</div><strong className={tone==='good'?'good-text':tone==='bad'?'bad-text':''}>{displayValue??formatMoney(value)}</strong>{hint&&<small className="stat-metric-hint">{hint}</small>}{children&&<details className="stat-metric-details"><summary>Zusammensetzung <span></span></summary><div>{children}</div></details>}</div>; const autoTerm=statTermKeyForLabel(label);
const definition=autoTerm?STATS_GLOSSARY[autoTerm]:null;
const fullHelp=definition?<><StatsTermExplanation definition={definition}/>{help&&<div className="stats-context-help"><strong>Für diese konkrete Karte</strong>{help}</div>}</>:help;
return <div className={`metric stat-explain-metric ${featured?'featured':''}`}><div className="stat-metric-label"><span>{label}</span>{fullHelp&&<StatsInfoButton title={helpTitle||label}>{fullHelp}</StatsInfoButton>}</div><strong className={tone==='good'?'good-text':tone==='bad'?'bad-text':''}>{displayValue??formatMoney(value)}</strong>{hint&&<small className="stat-metric-hint">{hint}</small>}{children&&<details className="stat-metric-details"><summary>Zusammensetzung <span></span></summary><div>{children}</div></details>}</div>;
} }
function StatsMethodPanel({year}){ function StatsMethodPanel({year}){
return <section className="panel stats-method-panel"><div className="section-head"><StatsSectionTitle title="So rechnet diese Statistik" helpTitle="Grundprinzip der Statistik" help={<><p>Die Statistik ist eine Planungs- und Durchschnittssicht für das gewählte Kalenderjahr. Sie ist keine Liste tatsächlich gebuchter Bankumsätze.</p><p><strong>Ø / Monat</strong> bedeutet immer: im Jahr eingeplanter Betrag ÷ 12. Dadurch werden jährliche, vierteljährliche, halbjährliche und einmalige Positionen vergleichbar.</p><p>Transfers bleiben wirtschaftlich getrennt von Einnahmen und Kosten. In einer einzelnen Kontosicht gehören sie trotzdem zum Liquiditäts-Nettofluss.</p></>}><p>Alle Ø-Werte beziehen sich auf <strong>{year}</strong>. Einmalige Positionen zählen genau einmal im Zieljahr. Periodische Positionen mit Fälligkeitsmonat werden in ihren echten Monaten eingeplant.</p></StatsSectionTitle></div><div className="stats-method-grid"><div><strong>Wirtschaftlich</strong><span>Echte Einnahmen minus echte Ausgaben. Transfers zählen nicht als Gewinn oder Kosten.</span></div><div><strong>Personen</strong><span>Ausgaben werden nach den Personen der Bewegung geteilt. Persönliches Einkommen folgt in der Gesamtansicht dem Eigentümer des persönlichen Kontos.</span></div><div><strong>Gemeinschaft</strong><span>Gemeinsame Einnahmen decken zuerst gemeinsame Kosten und werden niemals zu persönlichem Einkommen.</span></div><div><strong>Liquidität</strong><span>Bei einem einzelnen Konto werden Transfers rein und raus im Nettofluss vollständig berücksichtigt.</span></div></div></section>; return <section className="panel stats-method-panel"><div className="section-head"><StatsSectionTitle title="So rechnet diese Statistik" helpTitle="Grundprinzip der Statistik" help={<><p>Die Statistik ist eine Planungs- und Durchschnittssicht für das gewählte Kalenderjahr. Sie ist keine Liste tatsächlich gebuchter Bankumsätze.</p><p><strong>Ø / Monat</strong> bedeutet immer: im Jahr eingeplanter Betrag ÷ 12. Dadurch werden jährliche, vierteljährliche, halbjährliche und einmalige Positionen vergleichbar.</p><p>Transfers bleiben wirtschaftlich getrennt von Einnahmen und Kosten. In einer einzelnen Kontosicht gehören sie trotzdem zum Liquiditäts-Nettofluss.</p></>}><p>Alle Ø-Werte beziehen sich auf <strong>{year}</strong>. Einmalige Positionen zählen genau einmal im Zieljahr. Periodische Positionen mit Fälligkeitsmonat werden in ihren echten Monaten eingeplant. Jedes kleine <strong>i</strong> öffnet eine vollständige Definition mit Formel, Ein- und Ausschlüssen, Interpretation und Beispiel.</p></StatsSectionTitle><StatsGlossaryButton/></div><div className="stats-method-grid"><div><strong>Wirtschaftlich</strong><span>Echte Einnahmen minus echte Ausgaben. Transfers zählen nicht als Gewinn oder Kosten.</span></div><div><strong>Personen</strong><span>Ausgaben werden nach den Personen der Bewegung geteilt. Persönliches Einkommen folgt in der Gesamtansicht dem Eigentümer des persönlichen Kontos.</span></div><div><strong>Gemeinschaft</strong><span>Gemeinsame Einnahmen decken zuerst gemeinsame Kosten und werden niemals zu persönlichem Einkommen.</span></div><div><strong>Liquidität</strong><span>Bei einem einzelnen Konto werden Transfers rein und raus im Nettofluss vollständig berücksichtigt.</span></div></div></section>;
} }
function PersonFlowOverview({stats,year}){ function PersonFlowOverview({stats,year}){
const help=<><p><strong>Wirtschaftlich verfügbar</strong> beantwortet: Was bleibt nach der persönlichen Kostenlast übrig? <strong>Liquid verfügbar</strong> beantwortet dagegen: Was bleibt auf persönlichen Zahlungsverkehrskonten nach echten Kontobewegungen übrig?</p><p>Diese beiden Werte dürfen unterschiedlich sein. Eine Überweisung ins Depot oder aufs Gemeinschaftskonto ist keine Ausgabe, reduziert aber die liquide Verfügbarkeit.</p></>; const help=<><p><strong>Wirtschaftlich verfügbar</strong> beantwortet: Was bleibt nach der persönlichen Kostenlast übrig? <strong>Liquid verfügbar</strong> beantwortet dagegen: Was bleibt auf persönlichen Zahlungsverkehrskonten nach echten Kontobewegungen übrig?</p><p>Diese beiden Werte dürfen unterschiedlich sein. Eine Überweisung ins Depot oder aufs Gemeinschaftskonto ist keine Ausgabe, reduziert aber die liquide Verfügbarkeit.</p></>;
if(!stats.isAll)return <section className="panel person-flow-panel"><div className="section-head"><StatsSectionTitle title="Personenbezogene Kontoflüsse" help={help}><p>Ø-Monatsfluss {year} auf dem ausgewählten Konto. Transfers sind immer im Nettofluss enthalten; der Schalter blendet nur Transferdetails aus.</p></StatsSectionTitle><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>Echte Eingänge</span><strong className="good-text">+{formatMoney(row.income)}</strong></div><div><span>Echte Ausgaben</span><strong className="bad-text">{formatMoney(row.expense)}</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><details className="stat-row-details"><summary>Rechenweg <span></span></summary><StatFormulaRows rows={[{label:'Zuflüsse = echte Eingänge + Transfers rein',value:row.displayIn,tone:'good'},{label:'Abflüsse = echte Ausgaben + Transfers raus',value:row.displayOut,tone:'bad'},{label:'Nettofluss = Zuflüsse Abflüsse',value:row.net,note:'Transfers werden hier liquiditätswirksam gerechnet.'}]}/></details></div>)}</div>}</section>; if(!stats.isAll)return <section className="panel person-flow-panel"><div className="section-head"><StatsSectionTitle title="Personenbezogene Kontoflüsse" help={help}><p>Ø-Monatsfluss {year} auf dem ausgewählten Konto. Transfers sind immer im Nettofluss enthalten; der Schalter blendet nur Transferdetails aus.</p></StatsSectionTitle><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><StatsTerm term="accountNetFlow" label="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><StatsTerm term="realIncome" label="Echte Eingänge"/><strong className="good-text">+{formatMoney(row.income)}</strong></div><div><StatsTerm term="realExpense" label="Echte Ausgaben"/><strong className="bad-text">{formatMoney(row.expense)}</strong></div>{stats.includeTransfers&&<><div><StatsTerm term="transferIn" label="Transfers rein"/><strong className="good-text">+{formatMoney(row.transferIn)}</strong></div><div><StatsTerm term="transferOut" label="Transfers raus"/><strong className="bad-text">{formatMoney(row.transferOut)}</strong></div></>}</div><details className="stat-row-details"><summary>Rechenweg <span></span></summary><StatFormulaRows rows={[{label:'Zuflüsse = echte Eingänge + Transfers rein',value:row.displayIn,tone:'good'},{label:'Abflüsse = echte Ausgaben + Transfers raus',value:row.displayOut,tone:'bad'},{label:'Nettofluss = Zuflüsse Abflüsse',value:row.net,note:'Transfers werden hier liquiditätswirksam gerechnet.'}]}/></details></div>)}</div>}</section>;
return <section className="panel person-flow-panel"><div className="section-head"><StatsSectionTitle title="Persönliche Sicht" help={help}><p>Wirtschaftliche Kosten und persönliche Liquidität als Ø-Monatswerte {year}. Transfers verändern Liquidität, bleiben aber außerhalb der Kostenstatistik.</p></StatsSectionTitle><strong className={`person-flow-total ${stats.totals.liquidAvailable>=0?'good-text':'bad-text'}`}>{stats.totals.liquidAvailable>=0?'+':''}{formatMoney(stats.totals.liquidAvailable)} Ø / Monat liquid</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>Liquid verfügbar auf persönlichen Zahlungsverkehrskonten</small></div><strong className={row.liquidAvailable>=0?'good-text':'bad-text'}>{row.liquidAvailable>=0?'+':''}{formatMoney(row.liquidAvailable)}</strong></div><div className="person-flow-grid"><div><span>Wirtschaftlich verfügbar</span><strong className={row.economicAvailable>=0?'good-text':'bad-text'}>{row.economicAvailable>=0?'+':''}{formatMoney(row.economicAvailable)}</strong></div><div><span>Brutto-Kostenanteil</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><div><span>Eigene Kontoeingänge</span><strong className="good-text">+{formatMoney(row.personalIncome)}</strong></div>{stats.includeTransfers&&<><div><span>Liquid-Transfers rein</span><strong className="good-text">+{formatMoney(row.liquidTransferIn)}</strong></div><div><span>Liquid-Transfers raus</span><strong className="bad-text">{formatMoney(row.liquidTransferOut)}</strong></div><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><details className="stat-row-details"><summary>Rechenweg <span></span></summary><StatFormulaRows rows={[{label:'Netto-Kostenlast = Brutto-Kostenanteil gemeinsame Deckung',value:row.netCost},{label:'Wirtschaftlich verfügbar = eigenes Einkommen Netto-Kostenlast',value:row.economicAvailable},{label:'Liquid verfügbar = liquide Eingänge Ausgaben + Transfers rein Transfers raus',value:row.liquidAvailable},{label:'Soll Gemeinschaftsfinanzierung',value:row.sharedFundingTarget},{label:'Ist Gemeinschaftsfinanzierung',value:row.sharedFundingActual},{label:'Abweichung Ist Soll',value:row.sharedFundingDifference}]}/></details></div>)}</div>}</section>; return <section className="panel person-flow-panel"><div className="section-head"><StatsSectionTitle title="Persönliche Sicht" help={help}><p>Wirtschaftliche Kosten und persönliche Liquidität als Ø-Monatswerte {year}. Transfers verändern Liquidität, bleiben aber außerhalb der Kostenstatistik.</p></StatsSectionTitle><strong className={`person-flow-total ${stats.totals.liquidAvailable>=0?'good-text':'bad-text'}`}>{stats.totals.liquidAvailable>=0?'+':''}{formatMoney(stats.totals.liquidAvailable)} Ø / Monat liquid</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><StatsTerm term="liquidAvailable" label="Liquid verfügbar auf persönlichen Zahlungsverkehrskonten"/></small></div><strong className={row.liquidAvailable>=0?'good-text':'bad-text'}>{row.liquidAvailable>=0?'+':''}{formatMoney(row.liquidAvailable)}</strong></div><div className="person-flow-grid"><div><StatsTerm term="economicAvailable" label="Wirtschaftlich verfügbar"/><strong className={row.economicAvailable>=0?'good-text':'bad-text'}>{row.economicAvailable>=0?'+':''}{formatMoney(row.economicAvailable)}</strong></div><div><StatsTerm term="grossCostShare" label="Brutto-Kostenanteil"/><strong className="bad-text">{formatMoney(row.expenseShare)}</strong></div><div><StatsTerm term="sharedCoverage" label="Gemeinsame Deckung"/><strong className="good-text">+{formatMoney(row.sharedCoverage)}</strong></div><div><StatsTerm term="netCost" label="Netto-Kostenlast"/><strong className="bad-text">{formatMoney(row.netCost)}</strong></div><div><StatsTerm term="personalIncome" label="Eigene Kontoeingänge"/><strong className="good-text">+{formatMoney(row.personalIncome)}</strong></div>{stats.includeTransfers&&<><div><StatsTerm term="liquidTransferIn" label="Liquid-Transfers rein"/><strong className="good-text">+{formatMoney(row.liquidTransferIn)}</strong></div><div><StatsTerm term="liquidTransferOut" label="Liquid-Transfers raus"/><strong className="bad-text">{formatMoney(row.liquidTransferOut)}</strong></div><div><StatsTerm term="sharedFunded" label="Gemeinschaft finanziert"/><strong>{formatMoney(row.sharedFundingActual)}</strong></div><div><StatsTerm term="assetBuild" label="Vermögensaufbau netto"/><strong className={row.assetBuild>=0?'good-text':'bad-text'}>{row.assetBuild>=0?'+':''}{formatMoney(row.assetBuild)}</strong></div></>}</div><details className="stat-row-details"><summary>Rechenweg <span></span></summary><StatFormulaRows rows={[{label:'Netto-Kostenlast = Brutto-Kostenanteil gemeinsame Deckung',value:row.netCost},{label:'Wirtschaftlich verfügbar = eigenes Einkommen Netto-Kostenlast',value:row.economicAvailable},{label:'Liquid verfügbar = liquide Eingänge Ausgaben + Transfers rein Transfers raus',value:row.liquidAvailable},{label:'Soll Gemeinschaftsfinanzierung',value:row.sharedFundingTarget},{label:'Ist Gemeinschaftsfinanzierung',value:row.sharedFundingActual},{label:'Abweichung Ist Soll',value:row.sharedFundingDifference}]}/></details></div>)}</div>}</section>;
} }
function SharedFundingOverview({data,personId='all',showTransfers=true,year=new Date().getFullYear()}){ function SharedFundingOverview({data,personId='all',showTransfers=true,year=new Date().getFullYear()}){
@@ -596,7 +673,7 @@ function SharedFundingOverview({data,personId='all',showTransfers=true,year=new
if(!stats.groups.length)return null; if(!stats.groups.length)return null;
const percent=value=>value==null?'':`${value.toFixed(1).replace('.',',')} %`; const percent=value=>value==null?'':`${value.toFixed(1).replace('.',',')} %`;
const help=<><p><strong>Kosten-Finanzierungsbedarf</strong> = gemeinsame Kosten gemeinsame Einnahmen, mindestens 0.</p><p><strong>Soll-Finanzierung</strong> verteilt diesen Bedarf im Verhältnis der tatsächlich zugeordneten gemeinsamen Kosten. <strong>Ist-Beitrag</strong> stammt aus Transfers von persönlichen Konten in diesen Gemeinschaftsbereich und wird dem Eigentümer des Quellkontos zugerechnet.</p><p><strong>Nettoveränderung Gemeinschaftsvermögen</strong> = gemeinsame Einnahmen + externe Zuführungen gemeinsame Kosten externe Entnahmen. Interne Umbuchungen zwischen gemeinsamen Konten desselben Eigentümerkreises fallen heraus.</p></>; const help=<><p><strong>Kosten-Finanzierungsbedarf</strong> = gemeinsame Kosten gemeinsame Einnahmen, mindestens 0.</p><p><strong>Soll-Finanzierung</strong> verteilt diesen Bedarf im Verhältnis der tatsächlich zugeordneten gemeinsamen Kosten. <strong>Ist-Beitrag</strong> stammt aus Transfers von persönlichen Konten in diesen Gemeinschaftsbereich und wird dem Eigentümer des Quellkontos zugerechnet.</p><p><strong>Nettoveränderung Gemeinschaftsvermögen</strong> = gemeinsame Einnahmen + externe Zuführungen gemeinsame Kosten externe Entnahmen. Interne Umbuchungen zwischen gemeinsamen Konten desselben Eigentümerkreises fallen heraus.</p></>;
return <section className="panel shared-finance-panel"><div className="section-head"><StatsSectionTitle title="Gemeinschaftsfinanzierung" help={help}><p>Ø-Monatswerte {year}. Gemeinsame Einnahmen bleiben im gemeinsamen Topf und reduzieren den Finanzierungsbedarf.</p></StatsSectionTitle></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><div className="shared-net-change"><span>Nettoveränderung Gemeinschaftsvermögen</span><strong className={group.netAssetChange>=0?'good-text':'bad-text'}>{group.netAssetChange>=0?'+':''}{formatMoney(group.netAssetChange)} Ø / Monat</strong></div></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 externe Zuführung</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 weder Kosten noch externe Finanzierung.</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)} · {percent(row.costShare)}</strong></div><div><span>Soll-Finanzierung</span><strong>{formatMoney(row.targetFunding)} · {percent(row.targetFundingShare)}</strong></div>{showTransfers&&<><div><span>Ist-Beitrag</span><strong>{formatMoney(row.actualFunding)} · {percent(row.actualFundingShare)}</strong></div><div><span>Anteil am Bedarf</span><strong>{percent(row.needCoverageShare)}</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><details className="stat-group-details"><summary>Zusammensetzung des Gemeinschaftsbereichs <span></span></summary><div className="stat-group-detail-content"><h4>Gemeinsame Einnahmen</h4><StatItemList data={data} items={group.incomeItems}/><h4>Gemeinsame Kosten</h4><StatItemList data={data} items={group.expenseItems}/>{showTransfers&&<><h4>Externe Zuführungen</h4><StatItemList data={data} items={group.incomingTransfers}/><h4>Interne Umbuchungen</h4><StatItemList data={data} items={group.internalTransferItems}/><h4>Externe Entnahmen</h4><StatItemList data={data} items={group.outgoingTransfers}/></>}</div></details>{showTransfers&&<div className="shared-internal-note">Ist-Beitrag % ist der Anteil an allen persönlichen Einzahlungen in den Gemeinschaftsbereich. Anteil am Bedarf zeigt, wie viel des verbleibenden Kostenbedarfs diese Person tatsächlich deckt.</div>}{!showTransfers&&<div className="shared-internal-note">Transferdetails sind ausgeblendet. Kostenanteil und Soll bleiben sichtbar; tatsächliche Beiträge nicht.</div>}</article>)}</div></section>; return <section className="panel shared-finance-panel"><div className="section-head"><StatsSectionTitle title="Gemeinschaftsfinanzierung" help={help}><p>Ø-Monatswerte {year}. Gemeinsame Einnahmen bleiben im gemeinsamen Topf und reduzieren den Finanzierungsbedarf.</p></StatsSectionTitle></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><div className="shared-net-change"><StatsTerm term="sharedNetChange" label="Nettoveränderung Gemeinschaftsvermögen"/><strong className={group.netAssetChange>=0?'good-text':'bad-text'}>{group.netAssetChange>=0?'+':''}{formatMoney(group.netAssetChange)} Ø / Monat</strong></div></div><div className="shared-finance-summary"><div><StatsTerm term="sharedIncome" label="Gemeinsame Einnahmen"/><strong className="good-text">+{formatMoney(group.income)}</strong></div><div><StatsTerm term="sharedCost" label="Gemeinsame Kosten"/><strong className="bad-text">{formatMoney(group.expense)}</strong></div><div><StatsTerm term="sharedNeed" label="Kosten-Finanzierungsbedarf"/><strong>{formatMoney(group.costNeed)}</strong></div>{showTransfers&&<div><StatsTerm term="externalFunding" label="Tatsächliche externe Zuführung"/><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 weder Kosten noch externe Finanzierung.</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><StatsTerm term="costShare" label="Kostenanteil"/><strong>{formatMoney(row.grossCost)} · {percent(row.costShare)}</strong></div><div><StatsTerm term="targetFunding" label="Soll-Finanzierung"/><strong>{formatMoney(row.targetFunding)} · {percent(row.targetFundingShare)}</strong></div>{showTransfers&&<><div><StatsTerm term="actualContribution" label="Ist-Beitrag"/><strong>{formatMoney(row.actualFunding)} · {percent(row.actualFundingShare)}</strong></div><div><StatsTerm term="needCoverage" label="Anteil am Bedarf"/><strong>{percent(row.needCoverageShare)}</strong></div><div><StatsTerm term="fundingDifference" label="Differenz"/><strong className={row.difference>=0?'good-text':'bad-text'}>{row.difference>=0?'+':''}{formatMoney(row.difference)}</strong></div></>}</div>)}</div><details className="stat-group-details"><summary>Zusammensetzung des Gemeinschaftsbereichs <span></span></summary><div className="stat-group-detail-content"><h4><StatsTerm term="sharedIncome" label="Gemeinsame Einnahmen"/></h4><StatItemList data={data} items={group.incomeItems}/><h4><StatsTerm term="sharedCost" label="Gemeinsame Kosten"/></h4><StatItemList data={data} items={group.expenseItems}/>{showTransfers&&<><h4><StatsTerm term="externalFunding" label="Externe Zuführungen"/></h4><StatItemList data={data} items={group.incomingTransfers}/><h4><StatsTerm term="internalSharedTransfer" label="Interne Umbuchungen"/></h4><StatItemList data={data} items={group.internalTransferItems}/><h4><StatsTerm term="externalWithdrawal" label="Externe Entnahmen"/></h4><StatItemList data={data} items={group.outgoingTransfers}/></>}</div></details>{showTransfers&&<div className="shared-internal-note">Ist-Beitrag % ist der Anteil an allen persönlichen Einzahlungen in den Gemeinschaftsbereich. Anteil am Bedarf zeigt, wie viel des verbleibenden Kostenbedarfs diese Person tatsächlich deckt.</div>}{!showTransfers&&<div className="shared-internal-note">Transferdetails sind ausgeblendet. Kostenanteil und Soll bleiben sichtbar; tatsächliche Beiträge nicht.</div>}</article>)}</div></section>;
} }
function SharedIncomeBurdenOverview({data,personId='all',showTransfers=true,year=new Date().getFullYear()}){ function SharedIncomeBurdenOverview({data,personId='all',showTransfers=true,year=new Date().getFullYear()}){
@@ -605,7 +682,7 @@ function SharedIncomeBurdenOverview({data,personId='all',showTransfers=true,year
const percent=value=>value==null?'':`${value.toFixed(1).replace('.',',')} %`; const percent=value=>value==null?'':`${value.toFixed(1).replace('.',',')} %`;
const percentPoints=value=>value==null?'':`${value>=0?'+':''}${value.toFixed(1).replace('.',',')} %-Pkt.`; const percentPoints=value=>value==null?'':`${value>=0?'+':''}${value.toFixed(1).replace('.',',')} %-Pkt.`;
const help=<><p>Diese Sicht ist ein <strong>Fairnessvergleich</strong>, keine automatische Zahlungsregel.</p><p>Zuerst werden gemeinsame Einnahmen von gemeinsamen Kosten abgezogen. Der verbleibende Bedarf wird dann proportional zu den persönlichen Einkommen der Eigentümer verteilt. Deshalb entspricht der Soll-% dem Einkommensanteil.</p><p>Die Belastungsquote des Haushalts ist verbleibender Bedarf ÷ Summe der persönlichen Einkommen. Bei proportionaler Verteilung ist diese Quote für alle Personen mit Einkommen gleich.</p></>; const help=<><p>Diese Sicht ist ein <strong>Fairnessvergleich</strong>, keine automatische Zahlungsregel.</p><p>Zuerst werden gemeinsame Einnahmen von gemeinsamen Kosten abgezogen. Der verbleibende Bedarf wird dann proportional zu den persönlichen Einkommen der Eigentümer verteilt. Deshalb entspricht der Soll-% dem Einkommensanteil.</p><p>Die Belastungsquote des Haushalts ist verbleibender Bedarf ÷ Summe der persönlichen Einkommen. Bei proportionaler Verteilung ist diese Quote für alle Personen mit Einkommen gleich.</p></>;
return <section className="panel shared-income-burden-panel"><div className="section-head"><StatsSectionTitle title="Gemeinsame Kosten gemessen am persönlichen Einkommen" help={help}><p>Ø-Monatswerte {year}. Diese Vergleichssicht zeigt, wie der verbleibende Gemeinschaftsbedarf bei einkommensproportionaler Finanzierung verteilt wäre.</p></StatsSectionTitle></div><div className="shared-income-burden-list">{stats.groups.map(group=>{const personalIncomeItems=(data.incomes||[]).filter(item=>{const account=data.accounts.find(a=>a.id===item.accountId);return account?.ownership==='personal'&&ownerIdsOf(account).some(id=>group.ownerPersonIds.includes(id));}).map(item=>({...item,statAmount:statMonthlyAverage(item,year),statAnnual:plannedAmountForYear(item,year)})).filter(item=>item.statAnnual);return <article className="shared-income-burden-card" key={group.key}><div className="shared-income-burden-head"><div><strong>{group.accounts.map(a=>a.name).join(' + ')}</strong><span>{group.ownerPersonIds.map(id=>personName(data,id)).join(', ')}</span></div><div><span>Belastungsquote Haushalt</span><strong>{percent(group.householdBurdenRate)}</strong></div></div><div className="shared-income-burden-summary"><div><span>Gemeinsame Kosten</span><strong className="bad-text">{formatMoney(group.expense)}</strong></div><div><span>Deckung gemeinsame Einnahmen</span><strong className="good-text">+{formatMoney(group.sharedIncomeCoverage)}</strong></div><div><span>Verbleibender Bedarf</span><strong>{formatMoney(group.costNeed)}</strong></div><div><span>Persönliche Einkommen gesamt</span><strong>{formatMoney(group.personalIncomeTotal)}</strong></div></div>{group.sharedIncomeSurplus>0&&<div className="shared-income-burden-note good-text">Gemeinsame Einnahmen übersteigen die gemeinsamen Kosten um {formatMoney(group.sharedIncomeSurplus)} Ø / Monat. Es ist keine persönliche Finanzierung nötig.</div>}{!group.incomeAllocationAvailable?<div className="shared-income-burden-note">Keine persönlichen Einnahmen auf persönlichen Konten vorhanden. Eine einkommensbasierte Verteilung kann deshalb nicht berechnet werden.</div>:<div className="shared-income-person-list">{group.incomeBurdenRows.map(row=><div className={`shared-income-person-row ${showTransfers?'':'compact'}`} key={row.id}><span className="person-badge">{row.name}</span><div><span>Persönliches Einkommen</span><strong>{formatMoney(row.personalIncome)}</strong></div><div><span>Einkommensanteil / Soll-%</span><strong>{percent(row.incomeShare==null?null:row.incomeShare*100)}</strong></div><div><span>Soll nach Einkommen</span><strong>{row.incomeBasedFunding==null?'':formatMoney(row.incomeBasedFunding)}</strong></div><div><span>Belastung vom Einkommen</span><strong>{percent(row.burdenRate)}</strong></div>{showTransfers&&<><div><span>Ist-Beitrag / Ist-%</span><strong>{formatMoney(row.actualFunding)} · {percent(row.actualFundingShare==null?null:row.actualFundingShare*100)}</strong></div><div><span>Abweichung Soll / Ist</span><strong className={row.difference==null?'':row.difference>=0?'good-text':'bad-text'}>{row.difference==null?'':`${row.difference>=0?'+':''}${formatMoney(row.difference)} · ${percentPoints(row.shareDifferencePoints)}`}</strong></div></>}</div>)}</div>}<details className="stat-group-details"><summary>Rechenweg und Einkommensquellen <span></span></summary><div className="stat-group-detail-content"><StatFormulaRows rows={[{label:'Gemeinsame Kosten',value:group.expense},{label:' Deckung durch gemeinsame Einnahmen',value:group.sharedIncomeCoverage},{label:'= verbleibender Finanzierungsbedarf',value:group.costNeed},{label:'Persönliche Einkommen der Eigentümer',value:group.personalIncomeTotal},{label:'Haushalts-Belastungsquote = Bedarf ÷ Einkommen',value:percent(group.householdBurdenRate)}]}/><h4>Persönliche Einkommensquellen</h4><StatItemList data={data} items={personalIncomeItems}/></div></details><div className="shared-income-burden-note">{showTransfers?'Soll-% entspricht dem Einkommensanteil. Ist-% zeigt den Anteil an allen tatsächlichen persönlichen Einzahlungen. Die %-Punkt-Abweichung zeigt Über- oder Unterbeitrag relativ zum einkommensbasierten Soll. ':'Transferdetails sind ausgeblendet. '}Diese Sicht verändert keine Bewegungen oder Kontosalden.</div></article>})}</div></section>; return <section className="panel shared-income-burden-panel"><div className="section-head"><StatsSectionTitle title="Gemeinsame Kosten gemessen am persönlichen Einkommen" help={help}><p>Ø-Monatswerte {year}. Diese Vergleichssicht zeigt, wie der verbleibende Gemeinschaftsbedarf bei einkommensproportionaler Finanzierung verteilt wäre.</p></StatsSectionTitle></div><div className="shared-income-burden-list">{stats.groups.map(group=>{const personalIncomeItems=(data.incomes||[]).filter(item=>{const account=data.accounts.find(a=>a.id===item.accountId);return account?.ownership==='personal'&&ownerIdsOf(account).some(id=>group.ownerPersonIds.includes(id));}).map(item=>({...item,statAmount:statMonthlyAverage(item,year),statAnnual:plannedAmountForYear(item,year)})).filter(item=>item.statAnnual);return <article className="shared-income-burden-card" key={group.key}><div className="shared-income-burden-head"><div><strong>{group.accounts.map(a=>a.name).join(' + ')}</strong><span>{group.ownerPersonIds.map(id=>personName(data,id)).join(', ')}</span></div><div><StatsTerm term="householdBurden" label="Belastungsquote Haushalt"/><strong>{percent(group.householdBurdenRate)}</strong></div></div><div className="shared-income-burden-summary"><div><StatsTerm term="sharedCost" label="Gemeinsame Kosten"/><strong className="bad-text">{formatMoney(group.expense)}</strong></div><div><StatsTerm term="sharedIncomeCoverage" label="Deckung gemeinsame Einnahmen"/><strong className="good-text">+{formatMoney(group.sharedIncomeCoverage)}</strong></div><div><StatsTerm term="remainingNeed" label="Verbleibender Bedarf"/><strong>{formatMoney(group.costNeed)}</strong></div><div><StatsTerm term="personalIncomeTotal" label="Persönliche Einkommen gesamt"/><strong>{formatMoney(group.personalIncomeTotal)}</strong></div></div>{group.sharedIncomeSurplus>0&&<div className="shared-income-burden-note good-text">Gemeinsame Einnahmen übersteigen die gemeinsamen Kosten um {formatMoney(group.sharedIncomeSurplus)} Ø / Monat. Es ist keine persönliche Finanzierung nötig.</div>}{!group.incomeAllocationAvailable?<div className="shared-income-burden-note">Keine persönlichen Einnahmen auf persönlichen Konten vorhanden. Eine einkommensbasierte Verteilung kann deshalb nicht berechnet werden.</div>:<div className="shared-income-person-list">{group.incomeBurdenRows.map(row=><div className={`shared-income-person-row ${showTransfers?'':'compact'}`} key={row.id}><span className="person-badge">{row.name}</span><div><StatsTerm term="personalIncome" label="Persönliches Einkommen"/><strong>{formatMoney(row.personalIncome)}</strong></div><div><StatsTerm term="incomeShare" label="Einkommensanteil / Soll-%"/><strong>{percent(row.incomeShare==null?null:row.incomeShare*100)}</strong></div><div><StatsTerm term="incomeTarget" label="Soll nach Einkommen"/><strong>{row.incomeBasedFunding==null?'':formatMoney(row.incomeBasedFunding)}</strong></div><div><StatsTerm term="incomeBurden" label="Belastung vom Einkommen"/><strong>{percent(row.burdenRate)}</strong></div>{showTransfers&&<><div><StatsTerm term="actualContributionShare" label="Ist-Beitrag / Ist-%"/><strong>{formatMoney(row.actualFunding)} · {percent(row.actualFundingShare==null?null:row.actualFundingShare*100)}</strong></div><div><StatsTerm term="incomeDeviation" label="Abweichung Soll / Ist"/><strong className={row.difference==null?'':row.difference>=0?'good-text':'bad-text'}>{row.difference==null?'':`${row.difference>=0?'+':''}${formatMoney(row.difference)} · ${percentPoints(row.shareDifferencePoints)}`}</strong></div></>}</div>)}</div>}<details className="stat-group-details"><summary>Rechenweg und Einkommensquellen <span></span></summary><div className="stat-group-detail-content"><StatFormulaRows rows={[{label:'Gemeinsame Kosten',value:group.expense},{label:' Deckung durch gemeinsame Einnahmen',value:group.sharedIncomeCoverage},{label:'= verbleibender Finanzierungsbedarf',value:group.costNeed},{label:'Persönliche Einkommen der Eigentümer',value:group.personalIncomeTotal},{label:'Haushalts-Belastungsquote = Bedarf ÷ Einkommen',value:percent(group.householdBurdenRate)}]}/><h4><StatsTerm term="personalIncome" label="Persönliche Einkommensquellen"/></h4><StatItemList data={data} items={personalIncomeItems}/></div></details><div className="shared-income-burden-note">{showTransfers?'Soll-% entspricht dem Einkommensanteil. Ist-% zeigt den Anteil an allen tatsächlichen persönlichen Einzahlungen. Die %-Punkt-Abweichung zeigt Über- oder Unterbeitrag relativ zum einkommensbasierten Soll. ':'Transferdetails sind ausgeblendet. '}Diese Sicht verändert keine Bewegungen oder Kontosalden.</div></article>})}</div></section>;
} }
function dashboardKpis(data, year = new Date().getFullYear()) { function dashboardKpis(data, year = new Date().getFullYear()) {
@@ -646,6 +723,7 @@ function buildWarnings(data, includeTransfers = true){
for(const account of data.accounts){const f=annualForecast(data,account.id,year,'all',includeTransfers);const negatives=f.rows.slice(currentMonth).filter(r=>r.balance<0);if(negatives.length){const worst=negatives.reduce((a,b)=>b.balance<a.balance?b:a);warnings.push({kind:'danger',title:`${account.name}: Unterdeckung im ${worst.name}`,text:`Geplante Abflüsse übersteigen die Zuflüsse um ${formatMoney(Math.abs(worst.balance))}.`,sort:0});}} for(const account of data.accounts){const f=annualForecast(data,account.id,year,'all',includeTransfers);const negatives=f.rows.slice(currentMonth).filter(r=>r.balance<0);if(negatives.length){const worst=negatives.reduce((a,b)=>b.balance<a.balance?b:a);warnings.push({kind:'danger',title:`${account.name}: Unterdeckung im ${worst.name}`,text:`Geplante Abflüsse übersteigen die Zuflüsse um ${formatMoney(Math.abs(worst.balance))}.`,sort:0});}}
for(let m=currentMonth;m<12;m++){const due=(data.expenses||[]).filter(x=>isPeriodic(x)&&occursInMonth(x,m,year));if(due.length>=3){const total=due.reduce((sum,x)=>sum+effectiveAmount(x,year,m),0);warnings.push({kind:'warning',title:`${due.length} periodische Ausgaben im ${months[m]}`,text:`Zusammen ${formatMoney(total)}. Dieser Monat bündelt mehrere größere Fälligkeiten.`,sort:1});}} for(let m=currentMonth;m<12;m++){const due=(data.expenses||[]).filter(x=>isPeriodic(x)&&occursInMonth(x,m,year));if(due.length>=3){const total=due.reduce((sum,x)=>sum+effectiveAmount(x,year,m),0);warnings.push({kind:'warning',title:`${due.length} periodische Ausgaben im ${months[m]}`,text:`Zusammen ${formatMoney(total)}. Dieser Monat bündelt mehrere größere Fälligkeiten.`,sort:1});}}
for(const [type,items] of [['Eingang',data.incomes||[]],['Ausgang',data.expenses||[]]])for(const item of items){for(const change of item.amountChanges||[]){const d=new Date(`${change.effectiveFrom}T12:00:00`);if(d>=now&&d<=horizon)warnings.push({kind:'info',title:`${item.name}: Betrag ändert sich`,text:`${type} auf ${accountName(data,item.accountId)} ab ${formatDate(change.effectiveFrom)}: ${formatMoney(change.amount)}.`,sort:2,date:d});}if(item.endDate){const d=new Date(`${item.endDate}T12:00:00`);if(d>=now&&d<=horizon)warnings.push({kind:'info',title:`${item.name} läuft aus`,text:`${type} auf ${accountName(data,item.accountId)} endet am ${formatDate(item.endDate)}.`,sort:3,date:d});}} for(const [type,items] of [['Eingang',data.incomes||[]],['Ausgang',data.expenses||[]]])for(const item of items){for(const change of item.amountChanges||[]){const d=new Date(`${change.effectiveFrom}T12:00:00`);if(d>=now&&d<=horizon)warnings.push({kind:'info',title:`${item.name}: Betrag ändert sich`,text:`${type} auf ${accountName(data,item.accountId)} ab ${formatDate(change.effectiveFrom)}: ${formatMoney(change.amount)}.`,sort:2,date:d});}if(item.endDate){const d=new Date(`${item.endDate}T12:00:00`);if(d>=now&&d<=horizon)warnings.push({kind:'info',title:`${item.name} läuft aus`,text:`${type} auf ${accountName(data,item.accountId)} endet am ${formatDate(item.endDate)}.`,sort:3,date:d});}}
for(const item of data.transfers||[]){for(const change of item.amountChanges||[]){const d=new Date(`${change.effectiveFrom}T12:00:00`);if(d>=now&&d<=horizon)warnings.push({kind:'info',title:`${item.name}: Transferbetrag ändert sich`,text:`${accountName(data,item.fromAccountId)}${accountName(data,item.toAccountId)} ab ${formatDate(change.effectiveFrom)}: ${formatMoney(change.amount)}.`,sort:2,date:d});}}
return warnings.sort((a,b)=>a.sort-b.sort||(a.date?.getTime()||0)-(b.date?.getTime()||0)).slice(0,10); return warnings.sort((a,b)=>a.sort-b.sort||(a.date?.getTime()||0)-(b.date?.getTime()||0)).slice(0,10);
} }
@@ -839,7 +917,7 @@ function Dashboard({data,totals,onToggle,includeTransfers,onTransfersChange}){
</details> </details>
<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> <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>} {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><StatsTerm term="transferIn" label="Transfers rein"/><strong className="good-text">+{formatMoney(s.transferIn)}</strong></div><div><StatsTerm term="transferOut" label="Transfers raus"/><strong className="bad-text">{formatMoney(s.transferOut)}</strong></div></>}</div></div></details>)}</div>}
</section> </section>
<section className="panel fixfin-warnings"><div className="section-head"><div><h2>Hinweise</h2><p>FixFin prüft deinen aktuellen Plan auf Liquiditätslücken, gebündelte Fälligkeiten, anstehende Änderungen und auslaufende Positionen.</p></div><span className={`warning-count ${warnings.some(w=>w.kind==='danger')?'danger':''}`}>{warnings.length}</span></div>{warnings.length===0?<div className="warning-clear"><strong>Keine Auffälligkeiten</strong><span>Für die nächsten Monate sieht der Plan unauffällig aus.</span></div>:<div className="warning-list">{warnings.map((w,i)=><div className={`warning-item ${w.kind}`} key={`${w.title}-${i}`}><span className="warning-dot"/><div><strong>{w.title}</strong><span>{w.text}</span></div></div>)}</div>}</section> <section className="panel fixfin-warnings"><div className="section-head"><div><h2>Hinweise</h2><p>FixFin prüft deinen aktuellen Plan auf Liquiditätslücken, gebündelte Fälligkeiten, anstehende Änderungen und auslaufende Positionen.</p></div><span className={`warning-count ${warnings.some(w=>w.kind==='danger')?'danger':''}`}>{warnings.length}</span></div>{warnings.length===0?<div className="warning-clear"><strong>Keine Auffälligkeiten</strong><span>Für die nächsten Monate sieht der Plan unauffällig aus.</span></div>:<div className="warning-list">{warnings.map((w,i)=><div className={`warning-item ${w.kind}`} key={`${w.title}-${i}`}><span className="warning-dot"/><div><strong>{w.title}</strong><span>{w.text}</span></div></div>)}</div>}</section>
@@ -856,7 +934,7 @@ function PlanningPage({data,totals}){
return <div className="content-stack planning-page"> return <div className="content-stack planning-page">
<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> <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="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>Die geschlossene Zeile zeigt nur Konto und Ergebnis. Aufklappen zeigt, wie sich der Liquiditätsbedarf zusammensetzt.</p></div></div>{totals.accounts.length===0?<Empty text="Lege zuerst ein Konto an."/>:<div className="funding-grid funding-accordion-list">{totals.accounts.map(s=><details className="funding-card funding-accordion" key={s.account.id}><summary><div className="funding-summary-copy"><strong>{s.account.name}</strong><div className="compact-badges"><span className={`ownership-badge ${s.account.ownership||'unassigned'}`}>{accountOwnerships[s.account.ownership]||accountOwnerships.unassigned}</span><span className="account-function-badge">{accountFunctionLabel(s.account)}</span></div></div><div className="funding-summary-right"><span className={s.fundingNeed>0?'bad-text':'good-text'}>{s.fundingNeed>0?`${formatMoney(s.fundingNeed)} fehlen`:`${formatMoney(s.surplus)} übrig`}</span><span className="accordion-chevron" aria-hidden="true"></span></div></summary><div className="funding-detail"><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 className="funding-result"><span>Zusätzliche Zuführung</span><strong className={s.fundingNeed>0?'bad-text':'good-text'}>{formatMoney(s.fundingNeed)}</strong></div></div><p className="funding-formula">Ausgaben + Transfers raus Eingänge Transfers rein. Nur ein positiver Rest ist zusätzliche Zuführung.</p></div></details>)}</div>}</section> <section className="panel"><div className="section-head"><div><h2>Benötigt pro Konto</h2><p>Die geschlossene Zeile zeigt nur Konto und Ergebnis. Aufklappen zeigt, wie sich der Liquiditätsbedarf zusammensetzt.</p></div></div>{totals.accounts.length===0?<Empty text="Lege zuerst ein Konto an."/>:<div className="funding-grid funding-accordion-list">{totals.accounts.map(s=><details className="funding-card funding-accordion" key={s.account.id}><summary><div className="funding-summary-copy"><strong>{s.account.name}</strong><div className="compact-badges"><span className={`ownership-badge ${s.account.ownership||'unassigned'}`}>{accountOwnerships[s.account.ownership]||accountOwnerships.unassigned}</span><span className="account-function-badge">{accountFunctionLabel(s.account)}</span></div></div><div className="funding-summary-right"><span className={s.fundingNeed>0?'bad-text':'good-text'}>{s.fundingNeed>0?`${formatMoney(s.fundingNeed)} fehlen`:`${formatMoney(s.surplus)} übrig`}</span><span className="accordion-chevron" aria-hidden="true"></span></div></summary><div className="funding-detail"><div className="funding-numbers"><div><StatsTerm term="realExpense" label="Echte Ausgaben"/><strong>{formatMoney(s.expense)}</strong></div><div><StatsTerm term="transferOut" label="Transfers raus"/><strong>{formatMoney(s.transferOut)}</strong></div><div><StatsTerm term="realIncome" label="Echte Eingänge"/><strong>{formatMoney(s.income)}</strong></div><div><StatsTerm term="transferIn" label="Transfers rein"/><strong>{formatMoney(s.transferIn)}</strong></div><div className="funding-result"><span>Zusätzliche Zuführung</span><strong className={s.fundingNeed>0?'bad-text':'good-text'}>{formatMoney(s.fundingNeed)}</strong></div></div><p className="funding-formula">Ausgaben + Transfers raus Eingänge Transfers rein. Nur ein positiver Rest ist zusätzliche Zuführung.</p></div></details>)}</div>}</section>
<SharedFundingOverview data={data}/> <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> <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>; </div>;
@@ -925,8 +1003,8 @@ function Transfers({data,onEdit,onDuplicate,onDelete}){
const personLabelLocal=item=>transferPeopleLabel(data,item); const personLabelLocal=item=>transferPeopleLabel(data,item);
const dueText=movementDueText; const dueText=movementDueText;
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Quelle, Ziel, Betrag und Einordnung bleiben direkt sichtbar. Weitere Details und Aktionen liegen im Akkordeon.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<div className="movement-accordion-list">{data.transfers.map(x=>{const cls=transferClassification(data,x);return <details className="movement-accordion transfer-accordion" key={x.id}> return <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Quelle, Ziel, Betrag und Einordnung bleiben direkt sichtbar. Weitere Details und Aktionen liegen im Akkordeon.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<div className="movement-accordion-list">{data.transfers.map(x=>{const cls=transferClassification(data,x);return <details className="movement-accordion transfer-accordion" key={x.id}>
<summary><div className="movement-summary-main"><div className="movement-summary-copy"><strong>{x.name}</strong><span className="movement-summary-route">{accountNameLocal(x.fromAccountId)} <b></b> {accountNameLocal(x.toAccountId)}</span><div className="compact-badges"><span className={`transfer-class-badge ${cls.key}`}>{cls.label}</span><span className={`person-badge ${cls.key==='person-transfer'||personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span>{x.interval!=='monthly'&&<span className="due-badge">{dueText(x)}</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div></div><div className="movement-summary-value"><strong>{formatMoney(effectiveAmount(x))}</strong><small>{movementAmountHint(x)}</small></div><span className="accordion-chevron" aria-hidden="true"></span></div></summary> <summary><div className="movement-summary-main"><div className="movement-summary-copy"><strong>{x.name}</strong><span className="movement-summary-route">{accountNameLocal(x.fromAccountId)} <b></b> {accountNameLocal(x.toAccountId)}</span><div className="compact-badges"><span className={`transfer-class-badge ${cls.key}`}>{cls.label}</span><span className={`person-badge ${cls.key==='person-transfer'||personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span>{x.interval!=='monthly'&&<span className="due-badge">{dueText(x)}</span>}{(x.amountChanges||[]).length>0&&<span className="change-badge">{x.amountChanges.length} geplant</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div></div><div className="movement-summary-value"><strong>{formatMoney(effectiveAmount(x))}</strong><small>{movementAmountHint(x)}</small></div><span className="accordion-chevron" aria-hidden="true"></span></div></summary>
<div className="movement-accordion-body"><div className="movement-detail-grid"><div><span>Von</span><strong>{accountNameLocal(x.fromAccountId)}</strong></div><div><span>Nach</span><strong>{accountNameLocal(x.toAccountId)}</strong></div><div><span>Personen</span><strong>{personLabelLocal(x)}</strong></div><div><span>Einordnung</span><strong>{cls.description}</strong></div><div><span>Kategorie</span><strong>{categoryNameLocal(x.categoryId)}</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>{x.interval==='once'?'Einmalig':'Ø / Monat'}</span><strong>{x.interval==='once'?formatMoney(effectiveAmount(x)):formatMoney(monthlyEquivalent(x))}</strong></div>{x.endDate&&<div><span>Enddatum</span><strong>{formatDate(x.endDate)}</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></div> <div className="movement-accordion-body"><div className="movement-detail-grid"><div><span>Von</span><strong>{accountNameLocal(x.fromAccountId)}</strong></div><div><span>Nach</span><strong>{accountNameLocal(x.toAccountId)}</strong></div><div><span>Personen</span><strong>{personLabelLocal(x)}</strong></div><div><span>Einordnung</span><strong>{cls.description}</strong></div><div><span>Kategorie</span><strong>{categoryNameLocal(x.categoryId)}</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>{x.interval==='once'?'Einmalig':'Ø / Monat'}</span><strong>{x.interval==='once'?formatMoney(effectiveAmount(x)):formatMoney(monthlyEquivalent(x))}</strong></div>{x.endDate&&<div><span>Enddatum</span><strong>{formatDate(x.endDate)}</strong></div>}</div>{(x.amountChanges||[]).length>0&&<div className="movement-change-preview"><span>Geplante Betragsänderungen</span>{x.amountChanges.map(c=><div key={c.id||c.effectiveFrom}><strong>ab {formatDate(c.effectiveFrom)}</strong><span>{formatMoney(c.amount)}</span></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></div>
</details>})}</div>}</section>; </details>})}</div>}</section>;
} }
@@ -945,7 +1023,7 @@ function transferTypeStats(data,accountId='all',personId='all',year=new Date().g
function TransferTypeOverview({data,accountId='all',personId='all',year=new Date().getFullYear()}){ function TransferTypeOverview({data,accountId='all',personId='all',year=new Date().getFullYear()}){
const stats=useMemo(()=>transferTypeStats(data,accountId,personId,year),[data,accountId,personId,year]); const stats=useMemo(()=>transferTypeStats(data,accountId,personId,year),[data,accountId,personId,year]);
const help=<><p>Transfers werden nach Eigentum und Kontofunktion automatisch klassifiziert, z. B. Gemeinschaftsfinanzierung, Rücklagenzuführung oder Transfer zwischen Personen.</p><p>Die Beträge sind <strong>Ø-Monatswerte des gewählten Jahres</strong>. Sie werden nie zu echten Einnahmen oder Kosten addiert. Bei einem einzelnen Konto verändern sie aber den Liquiditäts-Nettofluss.</p></>; const help=<><p>Transfers werden nach Eigentum und Kontofunktion automatisch klassifiziert, z. B. Gemeinschaftsfinanzierung, Rücklagenzuführung oder Transfer zwischen Personen.</p><p>Die Beträge sind <strong>Ø-Monatswerte des gewählten Jahres</strong>. Sie werden nie zu echten Einnahmen oder Kosten addiert. Bei einem einzelnen Konto verändern sie aber den Liquiditäts-Nettofluss.</p></>;
return <section className="panel transfer-type-panel"><div className="section-head"><StatsSectionTitle title="Transfer- und Finanzierungsarten" help={help}><p>Separater Blick auf Umbuchungen {year}. Jede Karte lässt sich bis zu den konkreten Transfers aufklappen.</p></StatsSectionTitle></div>{stats.rows.length===0?<Empty text="Keine Transfers für diese Auswahl vorhanden."/>:<div className="transfer-type-grid">{stats.rows.map(row=><details className="transfer-type-card stat-transfer-type-card" key={row.key}><summary><div><span className={`transfer-class-badge ${row.key}`}>{row.label}</span><small>{row.description} · {row.count} {row.count===1?'Position':'Positionen'}</small></div><div><strong>{formatMoney(row.amount)} Ø / Monat</strong><small>{formatMoney(row.annual)} / Jahr</small>{!stats.isAll&&<div className="transfer-type-directions"><span>rein {formatMoney(row.in)}</span><span>raus {formatMoney(row.out)}</span></div>}</div><span className="accordion-chevron" aria-hidden="true"></span></summary><div className="stat-transfer-type-details"><StatItemList data={data} items={row.items}/></div></details>)}</div>}</section>; return <section className="panel transfer-type-panel"><div className="section-head"><StatsSectionTitle title="Transfer- und Finanzierungsarten" help={help}><p>Separater Blick auf Umbuchungen {year}. Jede Karte lässt sich bis zu den konkreten Transfers aufklappen. <StatsTerm term="transferType" label="Was bedeutet die Einordnung?"/></p></StatsSectionTitle></div>{stats.rows.length===0?<Empty text="Keine Transfers für diese Auswahl vorhanden."/>:<div className="transfer-type-grid">{stats.rows.map(row=><details className="transfer-type-card stat-transfer-type-card" key={row.key}><summary><div><span className={`transfer-class-badge ${row.key}`}>{row.label}</span><small>{row.description} · {row.count} {row.count===1?'Position':'Positionen'}</small></div><div><strong>{formatMoney(row.amount)} Ø / Monat</strong><small>{formatMoney(row.annual)} / Jahr</small>{!stats.isAll&&<div className="transfer-type-directions"><span><StatsTerm term="transferIn" label={`rein ${formatMoney(row.in)}`}/></span><span><StatsTerm term="transferOut" label={`raus ${formatMoney(row.out)}`}/></span></div>}</div><span className="accordion-chevron" aria-hidden="true"></span></summary><div className="stat-transfer-type-details"><StatItemList data={data} items={row.items}/></div></details>)}</div>}</section>;
} }
function CategoryDistribution({title,subtitle,stats,tone,data,year}){ function CategoryDistribution({title,subtitle,stats,tone,data,year}){
@@ -956,8 +1034,8 @@ function CategoryDistribution({title,subtitle,stats,tone,data,year}){
return `${item._statType==='income'?'Eingang':'Ausgang'} · ${accountName(data,item.accountId)}`; return `${item._statType==='income'?'Eingang':'Ausgang'} · ${accountName(data,item.accountId)}`;
} }
const help=<><p>Der Kategorienwert ist die Summe der <strong>Ø-Monatswerte {year}</strong> aller enthaltenen Positionen. Jede Position wird zuerst für das ganze Jahr geplant und anschließend durch 12 geteilt.</p><p>Bei einem Personenfilter werden gemeinsam zugeordnete Ausgaben gleichmäßig auf die ausgewählten Personen aufgeteilt. In der Gesamt-Personensicht werden persönliche Einnahmen nach Kontoeigentum bestimmt.</p><p>Der Prozentwert einer Kategorie ist Kategorienwert ÷ Gesamtsumme dieses Blocks.</p></>; const help=<><p>Der Kategorienwert ist die Summe der <strong>Ø-Monatswerte {year}</strong> aller enthaltenen Positionen. Jede Position wird zuerst für das ganze Jahr geplant und anschließend durch 12 geteilt.</p><p>Bei einem Personenfilter werden gemeinsam zugeordnete Ausgaben gleichmäßig auf die ausgewählten Personen aufgeteilt. In der Gesamt-Personensicht werden persönliche Einnahmen nach Kontoeigentum bestimmt.</p><p>Der Prozentwert einer Kategorie ist Kategorienwert ÷ Gesamtsumme dieses Blocks.</p></>;
return <section className="panel category-stat-panel"><div className="section-head"><StatsSectionTitle title={title} help={help}><p>{subtitle}</p></StatsSectionTitle><div className={`category-total ${tone==='income'?'good-text':tone==='expense'?'bad-text':''}`}><strong>{formatMoney(stats.total)} Ø / Monat</strong><small>{formatMoney(stats.annual)} / Jahr</small></div></div> return <section className="panel category-stat-panel"><div className="section-head"><StatsSectionTitle title={title} help={help}><p>{subtitle} <StatsTerm term="categoryShare" label="Wie entstehen Betrag und Prozentanteil?"/></p></StatsSectionTitle><div className={`category-total ${tone==='income'?'good-text':tone==='expense'?'bad-text':''}`}><strong>{formatMoney(stats.total)} Ø / Monat</strong><small>{formatMoney(stats.annual)} / Jahr</small></div></div>
{stats.rows.length===0?<Empty text="Noch keine Werte für diese Auswahl vorhanden."/>:<div className="category-distribution">{stats.rows.map((row,index)=><details className="category-stat-row" key={row.id}><summary><div className="category-stat-head"><div><span className="category-rank">{index+1}</span><strong>{row.name}</strong><small>{row.count} {row.count===1?'Position':'Positionen'}</small></div><div><strong>{formatMoney(row.amount)}</strong><span>{row.percent.toFixed(1).replace('.',',')} %</span></div></div><div className="category-bar"><span className={tone} style={{width:`${Math.max(row.percent,1)}%`}}/></div></summary><div className="category-drilldown"><div className="category-drill-formula"><span>{formatMoney(row.annual)} im Jahr ÷ 12</span><strong>= {formatMoney(row.amount)} Ø / Monat</strong></div>{row.items.sort((a,b)=>b.statAmount-a.statAmount).map((item,i)=><div className="category-drill-row" key={`${item.id}-${i}`}><div><strong>{item.name}</strong><span>{itemMeta(item)} · {personListLabel(data,item)} · {statAmountMeta(item)}</span></div><div><strong>{formatMoney(item.statAmount)} Ø / Monat</strong><small>{formatMoney(item.statAnnual)} / Jahr</small></div></div>)}</div></details>)}</div>} {stats.rows.length===0?<Empty text="Noch keine Werte für diese Auswahl vorhanden."/>:<div className="category-distribution">{stats.rows.map((row,index)=><details className="category-stat-row" key={row.id}><summary><div className="category-stat-head"><div><span className="category-rank">{index+1}</span><strong>{row.name}</strong><small>{row.count} {row.count===1?'Position':'Positionen'}</small></div><div><strong>{formatMoney(row.amount)}</strong><span className="category-percent-with-help">{row.percent.toFixed(1).replace('.',',')} % <StatsInfoButton title={STATS_GLOSSARY.categoryShare.title}><StatsTermExplanation definition={STATS_GLOSSARY.categoryShare}/></StatsInfoButton></span></div></div><div className="category-bar"><span className={tone} style={{width:`${Math.max(row.percent,1)}%`}}/></div></summary><div className="category-drilldown"><div className="category-drill-formula"><span>{formatMoney(row.annual)} im Jahr ÷ 12</span><strong>= {formatMoney(row.amount)} Ø / Monat</strong></div>{row.items.sort((a,b)=>b.statAmount-a.statAmount).map((item,i)=><div className="category-drill-row" key={`${item.id}-${i}`}><div><strong>{item.name}</strong><span>{itemMeta(item)} · {personListLabel(data,item)} · {statAmountMeta(item)}</span></div><div><strong>{formatMoney(item.statAmount)} Ø / Monat</strong><small>{formatMoney(item.statAnnual)} / Jahr</small></div></div>)}</div></details>)}</div>}
</section>; </section>;
} }
@@ -1005,7 +1083,7 @@ function StatisticsPage({data,includeTransfers,onTransfersChange}){
<StatMetricCard label={personalIncomeLabel} value={stats.incomes.total} tone="good" help={<><p>{accountId==='all'&&personId!=='all'?'Nur echte Eingänge auf persönlichen Konten, deren Eigentümer die ausgewählte Person ist. Gemeinsame Einnahmen werden nicht als persönliches Einkommen gezählt.':accountId!=='all'&&personId!=='all'?'Echte Eingänge auf dem ausgewählten Konto, anteilig gemäß Personenzuordnung der Bewegung.':'Echte Eingänge der aktuellen Auswahl.'}</p><p>Jeder Wert ist Jahresbetrag ÷ 12.</p></>}><StatItemList data={data} items={selectionIncomeItems}/></StatMetricCard> <StatMetricCard label={personalIncomeLabel} value={stats.incomes.total} tone="good" help={<><p>{accountId==='all'&&personId!=='all'?'Nur echte Eingänge auf persönlichen Konten, deren Eigentümer die ausgewählte Person ist. Gemeinsame Einnahmen werden nicht als persönliches Einkommen gezählt.':accountId!=='all'&&personId!=='all'?'Echte Eingänge auf dem ausgewählten Konto, anteilig gemäß Personenzuordnung der Bewegung.':'Echte Eingänge der aktuellen Auswahl.'}</p><p>Jeder Wert ist Jahresbetrag ÷ 12.</p></>}><StatItemList data={data} items={selectionIncomeItems}/></StatMetricCard>
<StatMetricCard label="Echte Kosten Ø / Monat" value={stats.expenses.total} tone="bad" help={<><p>Nur echte Ausgaben. Bei einer Person wird eine Bewegung mit mehreren zugeordneten Personen gleichmäßig geteilt.</p><p>Transfers bleiben vollständig getrennt.</p></>}><StatItemList data={data} items={selectionExpenseItems}/></StatMetricCard> <StatMetricCard label="Echte Kosten Ø / Monat" value={stats.expenses.total} tone="bad" help={<><p>Nur echte Ausgaben. Bei einer Person wird eine Bewegung mit mehreren zugeordneten Personen gleichmäßig geteilt.</p><p>Transfers bleiben vollständig getrennt.</p></>}><StatItemList data={data} items={selectionExpenseItems}/></StatMetricCard>
<StatMetricCard label={selectionBalanceLabel} value={selectionBalance} tone={selectionBalance>=0?'good':'bad'} help={<><p>{accountId==='all'?(personId==='all'?'Wirtschaftlicher Saldo: echte Einnahmen minus echte Kosten.':'Wirtschaftlich verfügbar: persönliches Einkommen minus persönliche Netto-Kostenlast nach gemeinsamer Deckung.'):'Liquiditäts-Nettofluss des Kontos: echte Eingänge + Transfers rein echte Ausgaben Transfers raus.'}</p></>}><StatFormulaRows rows={selectionBalanceRows}/></StatMetricCard> <StatMetricCard label={selectionBalanceLabel} value={selectionBalance} tone={selectionBalance>=0?'good':'bad'} help={<><p>{accountId==='all'?(personId==='all'?'Wirtschaftlicher Saldo: echte Einnahmen minus echte Kosten.':'Wirtschaftlich verfügbar: persönliches Einkommen minus persönliche Netto-Kostenlast nach gemeinsamer Deckung.'):'Liquiditäts-Nettofluss des Kontos: echte Eingänge + Transfers rein echte Ausgaben Transfers raus.'}</p></>}><StatFormulaRows rows={selectionBalanceRows}/></StatMetricCard>
<StatMetricCard label="Ohne Kategorie" displayValue={Number(uncategorized).toLocaleString('de-DE')} help={<><p>Anzahl echter Eingangs- und Ausgangspositionen der aktuellen Auswahl ohne Kategorie.</p><p>Transfers werden hier bewusst nicht mitgezählt, damit der Wert nicht vom Schalter Transferdetails anzeigen abhängt.</p></>}><StatItemList data={data} items={uncategorizedItems}/></StatMetricCard> <StatMetricCard label="Ohne Kategorie" displayValue={Number(uncategorized).toLocaleString('de-DE')} help={<StatsTermExplanation definition={STATS_GLOSSARY.uncategorized}/>}><StatItemList data={data} items={uncategorizedItems}/></StatMetricCard>
</div></section> </div></section>
<PersonFlowOverview stats={personFlows} year={year}/> <PersonFlowOverview stats={personFlows} year={year}/>
{accountId==='all'&&<SharedFundingOverview data={data} personId={personId} showTransfers={includeTransfers} year={year}/>} {accountId==='all'&&<SharedFundingOverview data={data} personId={personId} showTransfers={includeTransfers} year={year}/>}
@@ -1063,7 +1141,7 @@ function YearPage({data,includeTransfers,onTransfersChange}){
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> 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"> <div className="year-month-details">
{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>}</>:<> {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> <div className="year-flow-breakdown"><div><StatsTerm term="realIncome" label="Echte Eingänge"/><strong className="good-text">+{formatMoney(r.income)}</strong></div>{includeTransfers&&<div><StatsTerm term="transferIn" label="Transfers rein"/><strong className="good-text">+{formatMoney(r.transferIn)}</strong></div>}<div><StatsTerm term="realExpense" label="Echte Ausgaben"/><strong className="bad-text">{formatMoney(r.expense)}</strong></div>{includeTransfers&&<div><StatsTerm term="transferOut" label="Transfers raus"/><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>} {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>} {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: Der Nettofluss bleibt immer liquiditätsbasiert. Transfers sind keine Kosten, können hier aber separat ein- oder ausgeblendet werden.</div> <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>
@@ -1174,7 +1252,7 @@ function TransferAnalysisPage({data}){
<section className="summary-grid transfer-analysis-summary"><CountMetric label="Gefundene Transfers" value={stats.rows.length}/><Metric label={`Transfervolumen ${year}`} value={stats.annual}/><Metric label="Ø Transfervolumen / Monat" value={stats.monthly}/><Metric label={`Echte Einnahmen Quellen ${year}`} value={stats.sourceIncomeAnnual}/><StatMetricCard label="Transferquote zu Einnahmen" displayValue={percentValue(stats.incomeRatio)} featured helpTitle="Transferquote zu Einnahmen" help={<><p>Diese Quote beantwortet: <strong>Wie viel des echten Einkommens der ausgewählten Quellkonten wird über die aktuell ausgewählten Transfers weiterverteilt?</strong></p><p>Formel: ausgewähltes Transfervolumen ÷ echte Einnahmen der ausgewählten Quellkonten × 100.</p><p>Transfers, die auf einem Quellkonto eingehen, zählen nicht als Einnahmen. Die Quote kann über 100 % liegen, wenn zusätzlich vorhandenes Guthaben oder eingehende Transfers weiterverteilt werden.</p></>}><StatFormulaRows rows={[{label:'Ausgewählte Transfers',value:stats.annual},{label:'Echte Einnahmen der Quellkonten',value:stats.sourceIncomeAnnual},{label:'Transferquote',value:percentValue(stats.incomeRatio)}]}/></StatMetricCard><CountMetric label="Quellen mit Treffern" value={stats.sources.length}/></section> <section className="summary-grid transfer-analysis-summary"><CountMetric label="Gefundene Transfers" value={stats.rows.length}/><Metric label={`Transfervolumen ${year}`} value={stats.annual}/><Metric label="Ø Transfervolumen / Monat" value={stats.monthly}/><Metric label={`Echte Einnahmen Quellen ${year}`} value={stats.sourceIncomeAnnual}/><StatMetricCard label="Transferquote zu Einnahmen" displayValue={percentValue(stats.incomeRatio)} featured helpTitle="Transferquote zu Einnahmen" help={<><p>Diese Quote beantwortet: <strong>Wie viel des echten Einkommens der ausgewählten Quellkonten wird über die aktuell ausgewählten Transfers weiterverteilt?</strong></p><p>Formel: ausgewähltes Transfervolumen ÷ echte Einnahmen der ausgewählten Quellkonten × 100.</p><p>Transfers, die auf einem Quellkonto eingehen, zählen nicht als Einnahmen. Die Quote kann über 100 % liegen, wenn zusätzlich vorhandenes Guthaben oder eingehende Transfers weiterverteilt werden.</p></>}><StatFormulaRows rows={[{label:'Ausgewählte Transfers',value:stats.annual},{label:'Echte Einnahmen der Quellkonten',value:stats.sourceIncomeAnnual},{label:'Transferquote',value:percentValue(stats.incomeRatio)}]}/></StatMetricCard><CountMetric label="Quellen mit Treffern" value={stats.sources.length}/></section>
{sourceIds.length===0||targetIds.length===0?<section className="panel"><Empty text="Bitte mindestens ein Quell- und ein Zielkonto auswählen."/></section>:stats.rows.length===0?<section className="panel"><Empty text={`Für die gewählte Kombination gibt es ${year} keine geplanten Transfers.`}/></section>:<> {sourceIds.length===0||targetIds.length===0?<section className="panel"><Empty text="Bitte mindestens ein Quell- und ein Zielkonto auswählen."/></section>:stats.rows.length===0?<section className="panel"><Empty text={`Für die gewählte Kombination gibt es ${year} keine geplanten Transfers.`}/></section>:<>
<section className="panel transfer-source-overview"><div className="section-head"><div><h2>Wie viel kommt von welchem Konto?</h2><p>Jede Quelle zeigt ihr gesamtes Transfervolumen, die Verteilung auf die ausgewählten Zielkonten und den Anteil dieses Volumens an den echten Einnahmen des Quellkontos.</p></div><StatsInfoButton title="Transferquote je Quellkonto"><p><strong>Transferquote = ausgewählte Transfers dieses Quellkontos ÷ echte Einnahmen dieses Quellkontos × 100.</strong></p><p>Nur echte Eingänge zählen als Einnahmen. Eingehende Transfers sind kein Einkommen. Deshalb kann die Quote auch über 100 % liegen.</p></StatsInfoButton></div><div className="transfer-source-list">{stats.sources.map(source=><details className="transfer-source-card" key={source.accountId} open={stats.sources.length===1}><summary><div><strong>{accountName(data,source.accountId)}</strong><span>{source.items.length} {source.items.length===1?'Transfer':'Transfers'} · {percent(source.annual,stats.annual)} des ausgewählten Volumens · <b>{percentValue(source.incomeRatio)} der Einnahmen</b></span></div><div className="transfer-source-total"><strong>{formatMoney(source.annual)} / Jahr</strong><small>{formatMoney(source.monthly)} Ø / Monat</small></div><span className="accordion-chevron" aria-hidden="true"></span></summary><div className="transfer-source-body"><div className="transfer-income-ratio-strip"><div><span>Echte Einnahmen</span><strong>{formatMoney(source.incomeAnnual)} / Jahr</strong></div><div><span>Ausgewählte Transfers</span><strong>{formatMoney(source.annual)} / Jahr</strong></div><div><span>Transferquote</span><strong>{percentValue(source.incomeRatio)}</strong></div></div><div className="transfer-target-distribution">{source.targets.map(target=><div className="transfer-target-row" key={target.accountId}><div><strong>{accountName(data,target.accountId)}</strong><span>{target.items.length} {target.items.length===1?'Position':'Positionen'}</span></div><div className="transfer-target-bar"><span style={{width:`${Math.max(target.annual/source.annual*100,1)}%`}}/></div><div><strong>{formatMoney(target.annual)}</strong><span>{percent(target.annual,source.annual)}</span></div></div>)}</div><div className="transfer-analysis-detail-list">{source.items.sort((a,b)=>b.annual-a.annual).map(item=><details className="transfer-analysis-detail" key={item.id}><summary><div><strong>{item.name}</strong><span>{accountName(data,item.fromAccountId)} {accountName(data,item.toAccountId)}</span></div><div><strong>{formatMoney(item.annual)} / Jahr</strong><small>{percentValue(item.incomeRatio)} der Einnahmen der Quelle</small></div><span className="accordion-chevron" aria-hidden="true"></span></summary><div className="transfer-analysis-detail-body"><span className={`transfer-class-badge ${item.classification.key}`}>{item.classification.label}</span><div><span>Betrag je Intervall</span><strong>{formatMoney(item.amount)}</strong></div><div><span>Intervall</span><strong>{intervalLabel(item)}</strong></div><div><span>Fälligkeit</span><strong>{movementDueText(item)}</strong></div><div><span>Personen</span><strong>{transferPeopleLabel(data,item)}</strong></div><div><span>Kategorie</span><strong>{categoryName(data,item.categoryId)}</strong></div><div><span>Anteil dieser Quelle</span><strong>{percent(item.annual,source.annual)}</strong></div><div><span>Echte Einnahmen Quelle</span><strong>{formatMoney(item.sourceIncomeAnnual)} / Jahr</strong></div><div><span>Transfer / Einnahmen Quelle</span><strong>{percentValue(item.incomeRatio)}</strong></div></div></details>)}</div></div></details>)}</div></section> <section className="panel transfer-source-overview"><div className="section-head"><div><h2>Wie viel kommt von welchem Konto?</h2><p>Jede Quelle zeigt ihr gesamtes Transfervolumen, die Verteilung auf die ausgewählten Zielkonten und den Anteil dieses Volumens an den echten Einnahmen des Quellkontos.</p></div><StatsInfoButton title="Transferquote je Quellkonto"><p><strong>Transferquote = ausgewählte Transfers dieses Quellkontos ÷ echte Einnahmen dieses Quellkontos × 100.</strong></p><p>Nur echte Eingänge zählen als Einnahmen. Eingehende Transfers sind kein Einkommen. Deshalb kann die Quote auch über 100 % liegen.</p></StatsInfoButton></div><div className="transfer-source-list">{stats.sources.map(source=><details className="transfer-source-card" key={source.accountId} open={stats.sources.length===1}><summary><div><strong>{accountName(data,source.accountId)}</strong><span>{source.items.length} {source.items.length===1?'Transfer':'Transfers'} · {percent(source.annual,stats.annual)} des ausgewählten Volumens · <b>{percentValue(source.incomeRatio)} der Einnahmen</b></span></div><div className="transfer-source-total"><strong>{formatMoney(source.annual)} / Jahr</strong><small>{formatMoney(source.monthly)} Ø / Monat</small></div><span className="accordion-chevron" aria-hidden="true"></span></summary><div className="transfer-source-body"><div className="transfer-income-ratio-strip"><div><span>Echte Einnahmen</span><strong>{formatMoney(source.incomeAnnual)} / Jahr</strong></div><div><span>Ausgewählte Transfers</span><strong>{formatMoney(source.annual)} / Jahr</strong></div><div><span>Transferquote</span><strong>{percentValue(source.incomeRatio)}</strong></div></div><div className="transfer-target-distribution">{source.targets.map(target=><div className="transfer-target-row" key={target.accountId}><div><strong>{accountName(data,target.accountId)}</strong><span>{target.items.length} {target.items.length===1?'Position':'Positionen'}</span></div><div className="transfer-target-bar"><span style={{width:`${Math.max(target.annual/source.annual*100,1)}%`}}/></div><div><strong>{formatMoney(target.annual)}</strong><span>{percent(target.annual,source.annual)}</span></div></div>)}</div><div className="transfer-analysis-detail-list">{source.items.sort((a,b)=>b.annual-a.annual).map(item=><details className="transfer-analysis-detail" key={item.id}><summary><div><strong>{item.name}</strong><span>{accountName(data,item.fromAccountId)} {accountName(data,item.toAccountId)}</span></div><div><strong>{formatMoney(item.annual)} / Jahr</strong><small>{percentValue(item.incomeRatio)} der Einnahmen der Quelle</small></div><span className="accordion-chevron" aria-hidden="true"></span></summary><div className="transfer-analysis-detail-body"><span className={`transfer-class-badge ${item.classification.key}`}>{item.classification.label}</span><div><span>{(item.amountChanges||[]).length?'Basisbetrag je Intervall':'Betrag je Intervall'}</span><strong>{formatMoney(item.amount)}</strong></div><div><span>Intervall</span><strong>{intervalLabel(item)}</strong></div><div><span>Fälligkeit</span><strong>{movementDueText(item)}</strong></div><div><span>Personen</span><strong>{transferPeopleLabel(data,item)}</strong></div><div><span>Kategorie</span><strong>{categoryName(data,item.categoryId)}</strong></div><div><span>Anteil dieser Quelle</span><strong>{percent(item.annual,source.annual)}</strong></div><div><span>Echte Einnahmen Quelle</span><strong>{formatMoney(item.sourceIncomeAnnual)} / Jahr</strong></div><div><span>Transfer / Einnahmen Quelle</span><strong>{percentValue(item.incomeRatio)}</strong></div>{(item.amountChanges||[]).length>0&&<div className="transfer-analysis-changes"><span>Geplante Betragsänderungen</span><strong>{item.amountChanges.map(change=>`ab ${formatDate(change.effectiveFrom)}: ${formatMoney(change.amount)}`).join(' · ')}</strong></div>}</div></details>)}</div></div></details>)}</div></section>
<section className="panel transfer-target-overview"><div className="section-head"><div><h2>Verteilung auf die Zielkonten</h2><p>Wie viel des ausgewählten Transfervolumens insgesamt auf jedem Zielkonto landet. Zusätzlich siehst du, wie groß dieser Transferzufluss im Verhältnis zu den echten Einnahmen des Zielkontos ist.</p></div><StatsInfoButton title="Transferzufluss im Verhältnis zu Ziel-Einnahmen"><p>Diese Zielquote ist eine Ergänzung zur Quellquote: <strong>eingehende ausgewählte Transfers ÷ echte Einnahmen des Zielkontos × 100.</strong></p><p>Hat das Zielkonto keine echten Einnahmen, wird keine Prozentquote berechnet. Das ist kein Fehler, sondern bedeutet, dass das Konto ausschließlich oder zusätzlich durch Transfers gespeist wird.</p></StatsInfoButton></div><div className="transfer-target-cards">{stats.targets.map(target=><div className="transfer-target-card" key={target.accountId}><div><strong>{accountName(data,target.accountId)}</strong><span>{target.items.length} {target.items.length===1?'Transfer':'Transfers'}</span></div><strong>{formatMoney(target.annual)} / Jahr</strong><div className="transfer-target-card-foot"><span>{formatMoney(target.monthly)} Ø / Monat</span><b>{percent(target.annual,stats.annual)}</b></div><div className="transfer-target-income-ratio"><span>Echte Einnahmen: {formatMoney(target.incomeAnnual)}</span><strong>Transferzufluss / Einnahmen: {percentValue(target.incomeRatio)}</strong></div></div>)}</div></section> <section className="panel transfer-target-overview"><div className="section-head"><div><h2>Verteilung auf die Zielkonten</h2><p>Wie viel des ausgewählten Transfervolumens insgesamt auf jedem Zielkonto landet. Zusätzlich siehst du, wie groß dieser Transferzufluss im Verhältnis zu den echten Einnahmen des Zielkontos ist.</p></div><StatsInfoButton title="Transferzufluss im Verhältnis zu Ziel-Einnahmen"><p>Diese Zielquote ist eine Ergänzung zur Quellquote: <strong>eingehende ausgewählte Transfers ÷ echte Einnahmen des Zielkontos × 100.</strong></p><p>Hat das Zielkonto keine echten Einnahmen, wird keine Prozentquote berechnet. Das ist kein Fehler, sondern bedeutet, dass das Konto ausschließlich oder zusätzlich durch Transfers gespeist wird.</p></StatsInfoButton></div><div className="transfer-target-cards">{stats.targets.map(target=><div className="transfer-target-card" key={target.accountId}><div><strong>{accountName(data,target.accountId)}</strong><span>{target.items.length} {target.items.length===1?'Transfer':'Transfers'}</span></div><strong>{formatMoney(target.annual)} / Jahr</strong><div className="transfer-target-card-foot"><span>{formatMoney(target.monthly)} Ø / Monat</span><b>{percent(target.annual,stats.annual)}</b></div><div className="transfer-target-income-ratio"><span>Echte Einnahmen: {formatMoney(target.incomeAnnual)}</span><strong>Transferzufluss / Einnahmen: {percentValue(target.incomeRatio)}</strong></div></div>)}</div></section>
@@ -1282,7 +1360,7 @@ function EditorDialog({dialog,data,onClose,onSave}){
if(!selectedPersonIds.includes(newId))selectedPersonIds.push(newId); if(!selectedPersonIds.includes(newId))selectedPersonIds.push(newId);
} }
if(selectedPersonIds.length===0)return setLocalError('Bitte mindestens eine Person auswählen.'); if(selectedPersonIds.length===0)return setLocalError('Bitte mindestens eine Person auswählen.');
if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId,personIds:selectedPersonIds}; if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId,personIds:selectedPersonIds,amountChanges};
else if(type==='expenses') value={...common,accountId,categoryId:selectedCategoryId,personIds:selectedPersonIds,amountChanges,pushoverReminder}; else if(type==='expenses') value={...common,accountId,categoryId:selectedCategoryId,personIds:selectedPersonIds,amountChanges,pushoverReminder};
else value={...common,accountId,categoryId:selectedCategoryId,personIds:selectedPersonIds,amountChanges}; else value={...common,accountId,categoryId:selectedCategoryId,personIds:selectedPersonIds,amountChanges};
} }
@@ -1299,7 +1377,7 @@ function EditorDialog({dialog,data,onClose,onSave}){
{!showNewPerson?<button type="button" className="secondary person-add-inline" onClick={()=>setShowNewPerson(true)}>+ Neue Person</button>:<div className="new-category-field person-new-field"><label><span>Neue Person</span><input value={newPersonName} onChange={e=>setNewPersonName(e.target.value)} placeholder="z. B. Michael"/></label><button type="button" className="secondary" onClick={()=>{setShowNewPerson(false);setNewPersonName('');}}>Abbrechen</button></div>} {!showNewPerson?<button type="button" className="secondary person-add-inline" onClick={()=>setShowNewPerson(true)}>+ Neue Person</button>:<div className="new-category-field person-new-field"><label><span>Neue Person</span><input value={newPersonName} onChange={e=>setNewPersonName(e.target.value)} placeholder="z. B. Michael"/></label><button type="button" className="secondary" onClick={()=>{setShowNewPerson(false);setNewPersonName('');}}>Abbrechen</button></div>}
</>} </>}
{(type==='incomes'||type==='expenses')&&<label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>} {(type==='incomes'||type==='expenses')&&<label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>}
{(type==='incomes'||type==='expenses')&&interval!=='once'&&<section className="amount-change-panel"><div className="amount-change-head"><div><strong>Geplante Betragsänderungen</strong><span>Ein neuer Betrag gilt ab dem Monat des gewählten Datums. Der Basisbetrag oben bleibt für frühere Monate erhalten.</span></div><span className="change-count">{amountChanges.length}</span></div>{amountChanges.length>0&&<div className="amount-change-list">{amountChanges.map(c=><div className="amount-change-row" key={c.id}><div><span>ab {formatDate(c.effectiveFrom)}</span><strong>{formatMoney(c.amount)}</strong></div><button type="button" className="danger" onClick={()=>setAmountChanges(amountChanges.filter(x=>x.id!==c.id))}>Entfernen</button></div>)}</div>}<div className="amount-change-add"><label><span>Wirksam ab</span><input type="date" value={changeDate} onChange={e=>setChangeDate(e.target.value)}/></label><label><span>Neuer Betrag</span><div className="money-input"><input inputMode="decimal" value={changeAmount} onChange={e=>setChangeAmount(e.target.value)} placeholder="0,00"/><span></span></div></label><button type="button" className="secondary" onClick={addAmountChange}>+ Änderung</button></div></section>} {(type==='incomes'||type==='expenses'||type==='transfers')&&interval!=='once'&&<section className="amount-change-panel"><div className="amount-change-head"><div><strong>Geplante Betragsänderungen</strong><span>Ein neuer Betrag gilt ab dem Monat des gewählten Datums. Der Basisbetrag oben bleibt für frühere Monate erhalten.</span></div><span className="change-count">{amountChanges.length}</span></div>{amountChanges.length>0&&<div className="amount-change-list">{amountChanges.map(c=><div className="amount-change-row" key={c.id}><div><span>ab {formatDate(c.effectiveFrom)}</span><strong>{formatMoney(c.amount)}</strong></div><button type="button" className="danger" onClick={()=>setAmountChanges(amountChanges.filter(x=>x.id!==c.id))}>Entfernen</button></div>)}</div>}<div className="amount-change-add"><label><span>Wirksam ab</span><input type="date" value={changeDate} onChange={e=>setChangeDate(e.target.value)}/></label><label><span>Neuer Betrag</span><div className="money-input"><input inputMode="decimal" value={changeAmount} onChange={e=>setChangeAmount(e.target.value)} placeholder="0,00"/><span></span></div></label><button type="button" className="secondary" onClick={addAmountChange}>+ Änderung</button></div></section>}
{type==='expenses'&&<button type="button" className={`reminder-toggle ${pushoverReminder?'active':''}`} aria-pressed={pushoverReminder} onClick={()=>setPushoverReminder(x=>!x)}><span className={`checkbox-mark ${pushoverReminder?'checked':''}`}>{pushoverReminder?'✓':''}</span><span><strong>Pushover-Erinnerung</strong><small>Am 1. des Fälligkeitsmonats mit Betrag und Konto erinnern.</small></span></button>} {type==='expenses'&&<button type="button" className={`reminder-toggle ${pushoverReminder?'active':''}`} aria-pressed={pushoverReminder} onClick={()=>setPushoverReminder(x=>!x)}><span className={`checkbox-mark ${pushoverReminder?'checked':''}`}>{pushoverReminder?'✓':''}</span><span><strong>Pushover-Erinnerung</strong><small>Am 1. des Fälligkeitsmonats mit Betrag und Konto erinnern.</small></span></button>}
{type==='transfers'&&<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button type="submit" className="primary">Speichern</button></div></form></div>; {type==='transfers'&&<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button type="submit" className="primary">Speichern</button></div></form></div>;
} }
+35
View File
@@ -1531,3 +1531,38 @@ html.modal-open, body.modal-open { overflow:hidden !important; overscroll-behavi
.transfer-analysis-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .transfer-analysis-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }
.transfer-income-ratio-strip { grid-template-columns:1fr; } .transfer-income-ratio-strip { grid-template-columns:1fr; }
} }
/* v3.34: vollständige Statistik-Erklärungen */
.stat-term-label { display:inline-flex; align-items:center; gap:5px; min-width:0; vertical-align:middle; }
.stat-term-label > span { min-width:0; }
.stat-term-label .stats-info-button { width:17px; height:17px; flex:0 0 17px; padding:0; font-size:9px; border-color:#526782; background:#132033; color:#dce7f5; }
.stat-term-label .stats-info-button:hover { background:#263a59; border-color:#88a1c4; }
.stats-term-explanation { display:grid; gap:12px; }
.stats-term-explanation p { margin:0; padding:10px 11px; border:1px solid #2b3b52; border-radius:10px; background:#0d151f; color:#c1ccda; }
.stats-term-explanation p strong { color:#f2f5fa; }
.stats-term-explanation .stats-example { background:#111d2c; border-color:#3a5272; }
.stats-glossary-button { white-space:nowrap; align-self:flex-start; }
.stats-glossary { display:grid; gap:18px; max-height:62vh; overflow:auto; padding-right:4px; }
.stats-glossary > section { display:grid; gap:8px; }
.stats-glossary h3 { margin:0; color:#f0f4fa; font-size:13px; }
.stats-glossary-list { display:grid; gap:7px; }
.stats-glossary-list details { border:1px solid #2a394f; border-radius:10px; background:#0b121b; overflow:hidden; }
.stats-glossary-list summary { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 12px; cursor:pointer; color:#e6edf7; font-weight:700; font-size:10px; }
.stats-glossary-list details[open] summary { border-bottom:1px solid #26364b; }
.stats-glossary-list .stats-term-explanation { padding:10px; }
.category-percent-with-help { display:inline-flex !important; align-items:center; justify-content:flex-end; gap:4px; }
.category-percent-with-help .stats-info-button { width:16px; height:16px; min-width:16px; font-size:8px; }
.shared-finance-summary .stat-term-label,
.person-flow-grid .stat-term-label,
.shared-person-row .stat-term-label,
.shared-income-burden-summary .stat-term-label,
.shared-income-person-row .stat-term-label { color:inherit; }
.stat-formula-rows .stat-term-label .stats-info-button { width:16px; height:16px; min-width:16px; font-size:8px; }
@media (max-width:700px){
.stats-glossary-button { width:100%; justify-content:center; }
.stats-glossary { max-height:68vh; }
}
.stats-context-help { display:grid; gap:7px; padding-top:10px; border-top:1px solid #314157; }
.stats-context-help > strong { color:#eef2f8; }