diff --git a/README.md b/README.md index ad042f0..dcbf0cb 100644 --- a/README.md +++ b/README.md @@ -107,4 +107,4 @@ Die Jahresprognose verwendet kompakte Monats-Akkordeons. Monat, Eingänge, Ausg - Szenarien: Was-wäre-wenn-Anpassungen für zusätzliche Eingänge, Ausgänge und Transfers, ohne den Basisplan zu verändern. - Bewegungen können dupliziert und anschließend angepasst werden. -Das JSON-Datenmodell verwendet aktuell `version: 9`. Neben Konten und Kategorien gibt es eine zentrale Personenliste (`persons`). Eingänge, Ausgänge, Transfers und Szenario-Anpassungen können über `personId` einer Person zugeordnet werden. Neue Bewegungen benötigen in der Oberfläche eine Person; ältere Daten werden verlustfrei migriert und bleiben bis zur manuellen Zuordnung als „Ohne Person“ erhalten. Vor einer Migration bzw. jedem Speichern wird weiterhin eine `.bak`-Datei angelegt. +Das JSON-Datenmodell verwendet aktuell `version: 10`. Neben Konten und Kategorien gibt es eine zentrale Personenliste (`persons`). Eingänge, Ausgänge, Transfers und Szenario-Anpassungen können über `personIds` einer oder mehreren Personen zugeordnet werden. In personenbezogenen Auswertungen wird der Betrag einer gemeinsam zugeordneten Bewegung gleichmäßig auf die ausgewählten Personen verteilt; in Gesamtansichten wird die Bewegung nur einmal mit ihrem vollen Betrag gerechnet. Bestehende `personId`-Zuordnungen aus Version 9 werden automatisch in ein ein-elementiges `personIds`-Array migriert. Ältere Daten ohne Zuordnung bleiben als „Ohne Person“ erhalten. Vor einer Migration bzw. jedem Speichern wird weiterhin eine `.bak`-Datei angelegt. diff --git a/public/sw.js b/public/sw.js index 2d5ab0b..72c62e8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const SHELL_CACHE='fixfin-shell-v3.21'; +const SHELL_CACHE='fixfin-shell-v3.22'; const DATA_CACHE='fixfin-data-v1'; const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png']; diff --git a/server/index.js b/server/index.js index afcff19..b9910dd 100644 --- a/server/index.js +++ b/server/index.js @@ -11,17 +11,25 @@ const pushoverFile=process.env.PUSHOVER_FILE||path.join(dataDir,'pushover.json') const reminderHour=Math.min(23,Math.max(0,Number(process.env.PUSHOVER_REMINDER_HOUR||8))); const PUSHOVER_ENDPOINT='https://api.pushover.net/1/messages.json'; const validIntervals=new Set(['monthly','quarterly','semiannual','yearly']); const validScenarioTypes=new Set(['income','expense','transfer']); -const emptyData=()=>({version:9,settings:{includePeriodicInBalance:true},accounts:[],categories:[],persons:[],incomes:[],expenses:[],transfers:[],scenarios:[]}); +const emptyData=()=>({version:10,settings:{includePeriodicInBalance:true},accounts:[],categories:[],persons:[],incomes:[],expenses:[],transfers:[],scenarios:[]}); const isFiniteNumber=value=>typeof value==='number'&&Number.isFinite(value); function normalizeMovement(item={}) { return { ...item, interval: validIntervals.has(item.interval)?item.interval:'monthly', dueMonth: item.interval && item.interval!=='monthly' && Number.isInteger(Number(item.dueMonth)) ? Number(item.dueMonth) : null, endDate: typeof item.endDate==='string' && item.endDate ? item.endDate : null }; } function normalizeAmountChanges(changes){return Array.isArray(changes)?changes.map(c=>({id:typeof c.id==='string'&&c.id?c.id:crypto.randomUUID(),effectiveFrom:typeof c.effectiveFrom==='string'?c.effectiveFrom:'',amount:Number(c.amount||0)})).sort((a,b)=>a.effectiveFrom.localeCompare(b.effectiveFrom)):[];} -function normalizeCategorizedMovement(item={}) { const normalized=normalizeMovement(item); return { ...normalized, categoryId: typeof item.categoryId==='string' && item.categoryId ? item.categoryId : null, personId: typeof item.personId==='string' && item.personId ? item.personId : null }; } +function normalizePersonIds(item={}) { + const raw=Array.isArray(item.personIds)?item.personIds:(typeof item.personId==='string'&&item.personId?[item.personId]:[]); + return [...new Set(raw.filter(id=>typeof id==='string'&&id))]; +} +function normalizeCategorizedMovement(item={}) { + const normalized=normalizeMovement(item); + delete normalized.personId; + return { ...normalized, categoryId: typeof item.categoryId==='string' && item.categoryId ? item.categoryId : null, personIds: normalizePersonIds(item) }; +} 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 normalizeScenarioAdjustment(item={}) { const type=validScenarioTypes.has(item.type)?item.type:'expense'; - const base={id:item.id,name:item.name,type,amount:Number(item.amount||0),personId:typeof item.personId==='string'&&item.personId?item.personId:null}; + const base={id:item.id,name:item.name,type,amount:Number(item.amount||0),personIds:normalizePersonIds(item)}; if(type==='transfer') return {...base,fromAccountId:item.fromAccountId,toAccountId:item.toAccountId}; return {...base,accountId:item.accountId}; } @@ -29,7 +37,7 @@ function normalizeScenario(item={}) { return {id:item.id,name:item.name,createdA function normalizeData(input){ if(!input||typeof input!=='object')return input; return { - version:9, + version:10, settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false}, accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts, categories:Array.isArray(input.categories)?input.categories.map(({id,name})=>({id,name})):[], @@ -43,6 +51,16 @@ function normalizeData(input){ function isValidDateString(value){if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2}$/.test(value))return false;const d=new Date(`${value}T00:00:00Z`);return !Number.isNaN(d.getTime())&&d.toISOString().slice(0,10)===value;} function validateMovement(item){if(!item.id||typeof item.id!=='string')throw new Error('Eintrag ohne gültige ID.');if(!item.name||typeof item.name!=='string')throw new Error('Eintrag ohne Namen.');if(!isFiniteNumber(item.amount)||item.amount<0)throw new Error(`Ungültiger Betrag bei '${item.name}'.`);if(!validIntervals.has(item.interval))throw new Error(`Ungültiges Intervall bei '${item.name}'.`);if(item.dueMonth!==null&&item.dueMonth!==undefined&&(!Number.isInteger(item.dueMonth)||item.dueMonth<1||item.dueMonth>12))throw new Error(`Ungültiger Monat bei '${item.name}'.`);if(item.endDate!==null&&item.endDate!==undefined&&!isValidDateString(item.endDate))throw new Error(`Ungültiges Enddatum bei '${item.name}'.`);} function validateAmountChanges(item){if(!Array.isArray(item.amountChanges))throw new Error(`Ungültige Betragsänderungen bei '${item.name}'.`);const dates=new Set();for(const change of item.amountChanges){if(!change.id||typeof change.id!=='string')throw new Error(`Betragsänderung ohne ID bei '${item.name}'.`);if(!isValidDateString(change.effectiveFrom))throw new Error(`Ungültiges Änderungsdatum bei '${item.name}'.`);if(!isFiniteNumber(change.amount)||change.amount<0)throw new Error(`Ungültiger Änderungsbetrag bei '${item.name}'.`);if(item.endDate&&change.effectiveFrom.slice(0,7)>item.endDate.slice(0,7))throw new Error(`Betragsänderung bei '${item.name}' liegt nach dem Enddatum.`);if(dates.has(change.effectiveFrom))throw new Error(`Für '${item.name}' existieren zwei Änderungen am selben Datum.`);dates.add(change.effectiveFrom);}} +function validatePersonAssignments(item,personIds,label='Eintrag'){ + if(!Array.isArray(item.personIds))throw new Error(`Ungültige Personenzuordnung bei '${item.name||label}'.`); + const seen=new Set(); + for(const id of item.personIds){ + if(typeof id!=='string'||!id)throw new Error(`Ungültige Personenzuordnung bei '${item.name||label}'.`); + if(seen.has(id))throw new Error(`Person bei '${item.name||label}' doppelt zugeordnet.`); + if(!personIds.has(id))throw new Error(`Unbekannte Person bei '${item.name||label}'.`); + seen.add(id); + } +} function validateData(input){ if(!input||typeof input!=='object')throw new Error('Ungültiges JSON.'); for(const key of ['accounts','categories','persons','incomes','expenses','transfers','scenarios'])if(!Array.isArray(input[key]))throw new Error(`Feld '${key}' muss ein Array sein.`); @@ -52,8 +70,8 @@ function validateData(input){ for(const c of input.categories){if(!c.id||typeof c.id!=='string')throw new Error('Kategorie ohne gültige ID.');if(!c.name||typeof c.name!=='string'||!c.name.trim())throw new Error('Kategorie ohne Namen.');if(categoryIds.has(c.id))throw new Error('Doppelte Kategorie-ID.');const normalizedName=c.name.trim().toLocaleLowerCase('de-DE');if(categoryNames.has(normalizedName))throw new Error(`Kategorie '${c.name}' existiert bereits.`);categoryIds.add(c.id);categoryNames.add(normalizedName);} const personIds=new Set(); const personNames=new Set(); for(const p of input.persons){if(!p.id||typeof p.id!=='string')throw new Error('Person ohne gültige ID.');if(!p.name||typeof p.name!=='string'||!p.name.trim())throw new Error('Person ohne Namen.');if(personIds.has(p.id))throw new Error('Doppelte Personen-ID.');const normalizedName=p.name.trim().toLocaleLowerCase('de-DE');if(personNames.has(normalizedName))throw new Error(`Person '${p.name}' existiert bereits.`);personIds.add(p.id);personNames.add(normalizedName);} - for(const 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}'.`);if(item.personId!==null&&item.personId!==undefined&&!personIds.has(item.personId))throw new Error(`Unbekannte Person bei '${item.name}'.`);if(collection==='expenses'&&typeof item.pushoverReminder!=='boolean')throw new Error(`Ungültige Reminder-Einstellung bei '${item.name}'.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval!=='monthly'&&(item.dueMonth===null||item.dueMonth===undefined))throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Fälligkeitsmonat gesetzt sein.`);} - for(const t of input.transfers){validateMovement(t);if(!accountIds.has(t.fromAccountId)||!accountIds.has(t.toAccountId))throw new Error(`Unbekanntes Konto beim Transfer '${t.name}'.`);if(t.fromAccountId===t.toAccountId)throw new Error('Quell- und Zielkonto müssen verschieden sein.');if(t.categoryId!==null&&t.categoryId!==undefined&&!categoryIds.has(t.categoryId))throw new Error(`Unbekannte Kategorie beim Transfer '${t.name}'.`);if(t.personId!==null&&t.personId!==undefined&&!personIds.has(t.personId))throw new Error(`Unbekannte Person beim Transfer '${t.name}'.`);} + for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);validateAmountChanges(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie bei '${item.name}'.`);validatePersonAssignments(item,personIds);if(collection==='expenses'&&typeof item.pushoverReminder!=='boolean')throw new Error(`Ungültige Reminder-Einstellung bei '${item.name}'.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval!=='monthly'&&(item.dueMonth===null||item.dueMonth===undefined))throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Fälligkeitsmonat gesetzt sein.`);} + for(const t of input.transfers){validateMovement(t);if(!accountIds.has(t.fromAccountId)||!accountIds.has(t.toAccountId))throw new Error(`Unbekanntes Konto beim Transfer '${t.name}'.`);if(t.fromAccountId===t.toAccountId)throw new Error('Quell- und Zielkonto müssen verschieden sein.');if(t.categoryId!==null&&t.categoryId!==undefined&&!categoryIds.has(t.categoryId))throw new Error(`Unbekannte Kategorie beim Transfer '${t.name}'.`);validatePersonAssignments(t,personIds,'Transfer');} const scenarioIds=new Set(); for(const s of input.scenarios){ if(!s.id||typeof s.id!=='string')throw new Error('Szenario ohne gültige ID.'); @@ -66,7 +84,7 @@ function validateData(input){ if(!a.name||typeof a.name!=='string'||!a.name.trim())throw new Error(`Anpassung ohne Namen in '${s.name}'.`); if(!validScenarioTypes.has(a.type))throw new Error(`Ungültige Anpassungsart in '${s.name}'.`); if(!isFiniteNumber(a.amount)||a.amount<0)throw new Error(`Ungültiger Szenario-Betrag bei '${a.name}'.`); - if(a.personId!==null&&a.personId!==undefined&&!personIds.has(a.personId))throw new Error(`Unbekannte Person bei Szenario-Anpassung '${a.name}'.`); + validatePersonAssignments(a,personIds,'Szenario-Anpassung'); if(a.type==='transfer') { if(!accountIds.has(a.fromAccountId)||!accountIds.has(a.toAccountId))throw new Error(`Unbekanntes Konto bei Szenario-Transfer '${a.name}'.`); if(a.fromAccountId===a.toAccountId)throw new Error(`Quell- und Zielkonto bei '${a.name}' müssen verschieden sein.`); } else if(!accountIds.has(a.accountId))throw new Error(`Unbekanntes Konto bei Szenario-Anpassung '${a.name}'.`); } @@ -81,7 +99,7 @@ async function readPushoverConfig(){const value=await readJsonFile(pushoverFile, async function savePushoverConfig(input){const token=String(input?.token||'').trim();const user=String(input?.user||'').trim();if(!pushoverKeyPattern.test(token))throw new Error('Der Pushover Application API Token muss 30 alphanumerische Zeichen enthalten.');if(!pushoverKeyPattern.test(user))throw new Error('Der Pushover User/Group Key muss 30 alphanumerische Zeichen enthalten.');await writePrivateJson(pushoverFile,{token,user,updatedAt:new Date().toISOString()});return {configured:true,userMasked:maskKey(user)};} async function sendPushover(config,{title,message}){ const body=new URLSearchParams({token:config.token,user:config.user,title,message}); - const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.19'},body}); + const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.22'},body}); let payload={}; try{payload=await response.json();}catch{} if(!response.ok||payload.status!==1)throw new Error(Array.isArray(payload.errors)?payload.errors.join(' '):`Pushover-API Fehler (${response.status})`); return payload; @@ -116,7 +134,7 @@ async function runPushoverReminderCheck(){ } async function ensureDataFile(){await fs.mkdir(dataDir,{recursive:true});try{await fs.access(dataFile);}catch{await atomicWrite(emptyData(),false);}} -async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];const scenarioAdjustments=(parsed.scenarios||[]).flatMap(s=>s.adjustments||[]);if(parsed.version!==9||!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=>!Object.hasOwn(x,'personId'))||scenarioAdjustments.some(x=>!Object.hasOwn(x,'personId'))||(parsed.incomes||[]).some(x=>!Array.isArray(x.amountChanges))||(parsed.expenses||[]).some(x=>!Array.isArray(x.amountChanges)||!Object.hasOwn(x,'pushoverReminder'))||movements.some(x=>!Object.hasOwn(x,'endDate'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;} +async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];const scenarioAdjustments=(parsed.scenarios||[]).flatMap(s=>s.adjustments||[]);if(parsed.version!==10||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.persons)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||movements.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||scenarioAdjustments.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||(parsed.incomes||[]).some(x=>!Array.isArray(x.amountChanges))||(parsed.expenses||[]).some(x=>!Array.isArray(x.amountChanges)||!Object.hasOwn(x,'pushoverReminder'))||movements.some(x=>!Object.hasOwn(x,'endDate'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;} async function 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 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});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 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});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');const personSuffix=personId==='all'?'':`-${data.persons.find(p=>p.id===personId)?.name||'Person'}`.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}${personSuffix}.pdf"`);res.type('application/pdf').send(buffer);}catch(e){next(e);}});app.get('/api/pushover/config',async(_req,res,next)=>{try{const config=await readPushoverConfig();res.json(config?{configured:true,userMasked:maskKey(config.user)}:{configured:false,userMasked:''});}catch(e){next(e);}});app.put('/api/pushover/config',async(req,res,next)=>{try{res.json(await savePushoverConfig(req.body));}catch(e){next(e);}});app.delete('/api/pushover/config',async(_req,res,next)=>{try{await fs.rm(pushoverFile,{force:true});res.json({configured:false,userMasked:''});}catch(e){next(e);}});app.post('/api/pushover/test',async(_req,res,next)=>{try{const config=await readPushoverConfig();if(!config)throw new Error('Pushover ist noch nicht konfiguriert.');await sendPushover(config,{title:'FixFin · Test',message:'Pushover ist erfolgreich mit FixFin verbunden.'});res.json({ok:true});}catch(e){next(e);}}); const dist=path.resolve(__dirname,'../dist');app.use(express.static(dist));app.use((_req,res)=>res.sendFile(path.join(dist,'index.html')));app.use((error,_req,res,_next)=>{console.error(error);res.status(400).json({error:error.message||'Unbekannter Fehler'});});await ensureDataFile();app.listen(port,'0.0.0.0',()=>{console.log(`FixFin läuft auf Port ${port}`);console.log(`Datendatei: ${dataFile}`);console.log(`Pushover-Reminder: Prüfung am 1. des Monats ab ${String(reminderHour).padStart(2,'0')}:00 Uhr (${process.env.TZ||'System-Zeitzone'})`);});setTimeout(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),5000);setInterval(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),15*60*1000); diff --git a/server/reports.js b/server/reports.js index fd4a43a..af558e9 100644 --- a/server/reports.js +++ b/server/reports.js @@ -22,38 +22,49 @@ function effectiveAmount(item,year,monthIndex){ function accountName(data,id){return data.accounts.find(a=>a.id===id)?.name||'Unbekannt';} function categoryName(data,id){return data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie';} function personName(data,id){return data.persons?.find(p=>p.id===id)?.name||'Ohne Person';} +function personIdsOf(item){return Array.isArray(item?.personIds)?item.personIds.filter(Boolean):typeof item?.personId==='string'&&item.personId?[item.personId]:[];} +function personLabel(data,item){const ids=personIdsOf(item);return ids.length?ids.map(id=>personName(data,id)).join(', '):'Ohne Person';} +function personFactor(item,personId='all'){if(personId==='all')return 1;const ids=personIdsOf(item);return ids.includes(personId)&&ids.length?1/ids.length:0;} export function buildAnnualForecast(data,year,accountId,personId='all'){ - const personMatches=item=>personId==='all'||item.personId===personId; const rows=MONTHS.map((name,index)=>({name,index,income:0,expense:0,transferIn:0,transferOut:0,displayIncome:0,displayExpense:0,balance:0,items:[]})); - for(const item of data.incomes||[]) if(personMatches(item)&&item.accountId===accountId) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m);rows[m].income+=amount;rows[m].items.push({...item,kind:'income',reportAmount:amount});} - for(const item of data.expenses||[]) if(personMatches(item)&&item.accountId===accountId) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m);rows[m].expense+=amount;rows[m].items.push({...item,kind:'expense',reportAmount:amount});} - for(const item of data.transfers||[]) if(personMatches(item)&&(item.fromAccountId===accountId||item.toAccountId===accountId)) for(let m=0;m<12;m++) if(occursInMonth(item,year,m)){ - const amount=Number(item.amount||0); - 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});} + for(const item of data.incomes||[]){ + const factor=personFactor(item,personId); if(!factor||item.accountId!==accountId)continue; + for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m)*factor;rows[m].income+=amount;rows[m].items.push({...item,kind:'income',reportAmount:amount});} + } + for(const item of data.expenses||[]){ + const factor=personFactor(item,personId); if(!factor||item.accountId!==accountId)continue; + for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){const amount=effectiveAmount(item,year,m)*factor;rows[m].expense+=amount;rows[m].items.push({...item,kind:'expense',reportAmount:amount});} + } + for(const item of data.transfers||[]){ + const factor=personFactor(item,personId); if(!factor||(item.fromAccountId!==accountId&&item.toAccountId!==accountId))continue; + for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ + const amount=Number(item.amount||0)*factor; + 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});} + } } for(const r of rows){r.displayIncome=r.income+r.transferIn;r.displayExpense=r.expense+r.transferOut;r.balance=r.displayIncome-r.displayExpense;} return {year,accountId,rows,yearIncome:rows.reduce((s,r)=>s+r.displayIncome,0),yearExpense:rows.reduce((s,r)=>s+r.displayExpense,0)}; } function occurrenceRows(data,year,accountId,personId='all'){ - const personMatches=item=>personId==='all'||item.personId===personId; const result=[]; - const push=(item,kind,monthIndex,direction='')=>{ - const amount=kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex); - result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,person:personName(data,item.personId),category:categoryName(data,item.categoryId),amount}); + const push=(item,kind,monthIndex,direction='',factor=1)=>{ + const amount=(kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex))*factor; + result.push({monthIndex,month:MONTHS[monthIndex],kind,direction,name:item.name,person:personLabel(data,item),category:categoryName(data,item.categoryId),amount}); }; - for(const item of data.incomes||[])if(personMatches(item)&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'income',m); - for(const item of data.expenses||[])if(personMatches(item)&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m); - for(const item of data.transfers||[])if(personMatches(item)&&(item.fromAccountId===accountId||item.toAccountId===accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ - if(item.toAccountId===accountId)push(item,'transfer',m,'in'); - if(item.fromAccountId===accountId)push(item,'transfer',m,'out'); - } + 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.expenses||[]){const factor=personFactor(item,personId);if(factor&&item.accountId===accountId)for(let m=0;m<12;m++)if(occursInMonth(item,year,m))push(item,'expense',m,'',factor);} + for(const item of data.transfers||[]){const factor=personFactor(item,personId);if(factor&&(item.fromAccountId===accountId||item.toAccountId===accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ + if(item.toAccountId===accountId)push(item,'transfer',m,'in',factor); + if(item.fromAccountId===accountId)push(item,'transfer',m,'out',factor); + }} const order={income:0,transferIn:1,expense:2,transferOut:3}; const typeOrder=r=>r.kind==='income'?order.income:r.kind==='expense'?order.expense:r.direction==='in'?order.transferIn:order.transferOut; return result.sort((a,b)=>a.monthIndex-b.monthIndex||typeOrder(a)-typeOrder(b)||a.name.localeCompare(b.name,'de')); } + function kindLabel(row){return row.kind==='income'?'Eingang':row.kind==='expense'?'Ausgabe':row.direction==='in'?'Transfer rein':'Transfer raus';} function isInflow(row){return row.kind==='income'||row.direction==='in';} @@ -74,7 +85,7 @@ export async function createExcelReport(data,{year,accountId,personId='all'}={}) const statement=wb.addWorksheet('Kontoauszug',{views:[{state:'frozen',ySplit:4}]}); addTitle(statement,`FixFin Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson} · Planungsdaten · Erstellt am ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`,'G'); - statement.getRow(4).values=['Monat','Art','Bezeichnung','Person','Kategorie','Zufluss','Abfluss'];styleHeader(statement.getRow(4)); + statement.getRow(4).values=['Monat','Art','Bezeichnung','Personen','Kategorie','Zufluss','Abfluss'];styleHeader(statement.getRow(4)); rows.forEach((r,i)=>{const rr=i+5;statement.getRow(rr).values=[r.month,kindLabel(r),r.name,r.person,r.category,isInflow(r)?r.amount:null,isInflow(r)?null:r.amount];euroCell(statement.getCell(rr,6));euroCell(statement.getCell(rr,7));if(isInflow(r))statement.getCell(rr,6).font={color:{argb:'FF067647'}};else statement.getCell(rr,7).font={color:{argb:'FFB42318'}};}); const totalRow=rows.length+6;statement.getCell(totalRow,1).value='Jahressumme';statement.getCell(totalRow,1).font={bold:true};statement.getCell(totalRow,6).value=forecast.yearIncome;statement.getCell(totalRow,7).value=forecast.yearExpense;euroCell(statement.getCell(totalRow,6));euroCell(statement.getCell(totalRow,7));statement.getCell(totalRow,6).font={bold:true,color:{argb:'FF067647'}};statement.getCell(totalRow,7).font={bold:true,color:{argb:'FFB42318'}}; statement.autoFilter={from:'A4',to:'G4'};widths(statement,[16,18,30,20,24,18,18]); @@ -105,7 +116,7 @@ export async function createPdfReport(data,{year,accountId,personId='all'}={}){ drawPdfHeader(doc,`FixFin Kontoauszug ${reportYear}`,`${account.name} · ${selectedPerson} · Erstellt ${new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(new Date())}`); pdfMetricRow(doc,[{label:'Zuflüsse / Jahr',value:pdfMoney(forecast.yearIncome),positive:true},{label:'Abflüsse / Jahr',value:pdfMoney(forecast.yearExpense),negative:true},{label:'Nettofluss / Jahr',value:pdfMoney(forecast.yearIncome-forecast.yearExpense),positive:forecast.yearIncome>=forecast.yearExpense,negative:forecast.yearIncomer.monthIndex===monthIndex);const month=forecast.rows[monthIndex]; diff --git a/src/main.jsx b/src/main.jsx index 8ca65c7..0c00b72 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -10,7 +10,7 @@ const intervals = { semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 }, yearly: { label: 'Jährlich', divisor: 12, step: 12 }, }; -const emptyData = { version: 9, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], persons: [], incomes: [], expenses: [], transfers: [], scenarios: [] }; +const emptyData = { version: 10, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], persons: [], incomes: [], expenses: [], transfers: [], scenarios: [] }; function formatMoney(value) { return euro.format(Number(value || 0)); } function parseAmount(value) { @@ -75,6 +75,20 @@ function occursInMonth(item, monthIndex, year = new Date().getFullYear()) { } function categoryName(data, id) { return data.categories.find(c => c.id === id)?.name || 'Ohne Kategorie'; } function personName(data, id) { return data.persons?.find(p => p.id === id)?.name || 'Ohne Person'; } +function personIdsOf(item) { + if (Array.isArray(item?.personIds)) return [...new Set(item.personIds.filter(Boolean))]; + return typeof item?.personId === 'string' && item.personId ? [item.personId] : []; +} +function personListLabel(data, item) { + const ids = personIdsOf(item); + return ids.length ? ids.map(id => personName(data,id)).join(', ') : 'Ohne Person'; +} +function personFactor(item, personId = 'all') { + if (personId === 'all') return 1; + const ids = personIdsOf(item); + return ids.includes(personId) && ids.length ? 1 / ids.length : 0; +} +function personMatches(item, personId = 'all') { return personFactor(item,personId) > 0; } function accountName(data, id) { return data.accounts.find(a => a.id === id)?.name || 'Unbekannt'; } function calc(data) { @@ -112,20 +126,22 @@ function calc(data) { function annualForecast(data, accountId = 'all', year = new Date().getFullYear(), personId = 'all') { const isAll = !accountId || accountId === 'all'; - const personMatches = item => personId === 'all' || item.personId === personId; const rows = months.map((name, index) => ({ name, index, income: 0, expense: 0, transferIn: 0, transferOut: 0, balance: 0, items: [] })); - const relevantIncome = item => personMatches(item) && (isAll || item.accountId === accountId); - const relevantExpense = item => personMatches(item) && (isAll || item.accountId === accountId); - const relevantTransfer = item => personMatches(item) && (isAll || item.fromAccountId === accountId || item.toAccountId === accountId); + const relevantIncome = item => personMatches(item,personId) && (isAll || item.accountId === accountId); + const relevantExpense = item => personMatches(item,personId) && (isAll || item.accountId === accountId); + const relevantTransfer = item => personMatches(item,personId) && (isAll || item.fromAccountId === accountId || item.toAccountId === accountId); for (const item of data.incomes.filter(relevantIncome)) { - for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m); rows[m].income += amount; rows[m].items.push({ ...item, kind:'income', forecastAmount:amount }); } + const factor=personFactor(item,personId); + for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m)*factor; rows[m].income += amount; rows[m].items.push({ ...item, kind:'income', forecastAmount:amount }); } } for (const item of data.expenses.filter(relevantExpense)) { - for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m); rows[m].expense += amount; rows[m].items.push({ ...item, kind:'expense', forecastAmount:amount }); } + const factor=personFactor(item,personId); + for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { const amount=effectiveAmount(item,year,m)*factor; rows[m].expense += amount; rows[m].items.push({ ...item, kind:'expense', forecastAmount:amount }); } } for (const item of data.transfers.filter(relevantTransfer)) { + const factor=personFactor(item,personId); for (let m=0;m<12;m++) if (occursInMonth(item,m,year)) { - const amount = Number(item.amount || 0); + const amount = Number(item.amount || 0) * factor; if (isAll) { rows[m].transferIn += amount; rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'neutral', forecastAmount:amount }); } else { @@ -135,9 +151,6 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear() } } rows.forEach(r => { - // Zwei Perspektiven: - // Gesamtansicht = wirtschaftliche Sicht, Transfers bleiben saldoneutral. - // Einzelkonto = Liquiditätssicht, Transfers sind echte Zu-/Abflüsse des Kontos. r.displayIncome = isAll ? r.income : r.income + r.transferIn; r.displayExpense = isAll ? r.expense : r.expense + r.transferOut; r.balance = r.displayIncome - r.displayExpense; @@ -162,28 +175,34 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear() function categoryStats(data, accountId = 'all', personId = 'all') { const categoryMap = new Map((data.categories || []).map(c => [c.id, c.name])); const isAll = accountId === 'all'; - const personMatches = item => personId === 'all' || item.personId === personId; const aggregate = items => { const buckets = new Map(); for (const item of items) { + const factor=personFactor(item,personId); + if(!factor) continue; const key = item.categoryId || '__uncategorized__'; const name = categoryMap.get(item.categoryId) || 'Ohne Kategorie'; const current = buckets.get(key) || { id:key, name, amount:0, count:0, items:[], uncategorized:key==='__uncategorized__' }; - const amount = monthlyEquivalent(item); + const amount = monthlyEquivalent(item) * factor; current.amount += amount; current.count += 1; current.items.push({...item, statAmount:amount}); buckets.set(key,current); } const total = [...buckets.values()].reduce((sum,x)=>sum+x.amount,0); const rows = [...buckets.values()].sort((a,b)=>b.amount-a.amount).map(x=>({...x,percent:total>0?(x.amount/total)*100:0})); return { total, rows }; }; - if (isAll) return { incomes: aggregate(data.incomes.filter(personMatches).map(x=>({...x,_statType:'income'}))), expenses: aggregate(data.expenses.filter(personMatches).map(x=>({...x,_statType:'expense'}))), transfers: aggregate(data.transfers.filter(personMatches).map(x=>({...x,_statType:'transfer'}))), transfersMerged:false }; + if (isAll) return { + incomes: aggregate(data.incomes.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'income'}))), + expenses: aggregate(data.expenses.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'expense'}))), + transfers: aggregate(data.transfers.filter(x=>personMatches(x,personId)).map(x=>({...x,_statType:'transfer'}))), + transfersMerged:false + }; const incomeItems = [ - ...data.incomes.filter(x => personMatches(x) && x.accountId === accountId).map(x=>({...x,_statType:'income'})), - ...data.transfers.filter(x => personMatches(x) && x.toAccountId === accountId).map(x=>({...x,_statType:'transfer-in'})), + ...data.incomes.filter(x => personMatches(x,personId) && x.accountId === accountId).map(x=>({...x,_statType:'income'})), + ...data.transfers.filter(x => personMatches(x,personId) && x.toAccountId === accountId).map(x=>({...x,_statType:'transfer-in'})), ]; const expenseItems = [ - ...data.expenses.filter(x => personMatches(x) && x.accountId === accountId).map(x=>({...x,_statType:'expense'})), - ...data.transfers.filter(x => personMatches(x) && x.fromAccountId === accountId).map(x=>({...x,_statType:'transfer-out'})), + ...data.expenses.filter(x => personMatches(x,personId) && x.accountId === accountId).map(x=>({...x,_statType:'expense'})), + ...data.transfers.filter(x => personMatches(x,personId) && x.fromAccountId === accountId).map(x=>({...x,_statType:'transfer-out'})), ]; return { incomes: aggregate(incomeItems), expenses: aggregate(expenseItems), transfers:{total:0,rows:[]}, transfersMerged:true }; } @@ -198,35 +217,39 @@ function personFlowStats(data, accountId = 'all', personId = 'all') { if (!buckets.has(key)) buckets.set(key, { id:key, name:key===unassignedId?'Ohne Person':personName(data,key), - income:0, - expense:0, - transferIn:0, - transferOut:0, - transferInternal:0, - displayIn:0, - displayOut:0, - net:0, + income:0, expense:0, transferIn:0, transferOut:0, transferInternal:0, + displayIn:0, displayOut:0, net:0, }); return buckets.get(key); }; for (const person of data.persons || []) ensure(person.id); - const personMatches = item => personId === 'all' || item.personId === personId; + + const splitTargets = item => { + const ids=personIdsOf(item); + if(!ids.length) return personId==='all' ? [[unassignedId,1]] : []; + if(personId!=='all') return ids.includes(personId) ? [[personId,1/ids.length]] : []; + return ids.map(id=>[id,1/ids.length]); + }; + for (const item of data.incomes || []) { - if (!personMatches(item) || (!isAll && item.accountId !== accountId)) continue; - ensure(item.personId).income += monthlyEquivalent(item); + if (!isAll && item.accountId !== accountId) continue; + const amount=monthlyEquivalent(item); + for(const [id,factor] of splitTargets(item)) ensure(id).income += amount*factor; } for (const item of data.expenses || []) { - if (!personMatches(item) || (!isAll && item.accountId !== accountId)) continue; - ensure(item.personId).expense += monthlyEquivalent(item); + if (!isAll && item.accountId !== accountId) continue; + const amount=monthlyEquivalent(item); + for(const [id,factor] of splitTargets(item)) ensure(id).expense += amount*factor; } for (const item of data.transfers || []) { - if (!personMatches(item)) continue; const amount = monthlyEquivalent(item); - const bucket = ensure(item.personId); - if (isAll) bucket.transferInternal += amount; - else { - if (item.toAccountId === accountId) bucket.transferIn += amount; - if (item.fromAccountId === accountId) bucket.transferOut += amount; + for(const [id,factor] of splitTargets(item)) { + const bucket = ensure(id); const share=amount*factor; + if (isAll) bucket.transferInternal += share; + else { + if (item.toAccountId === accountId) bucket.transferIn += share; + if (item.fromAccountId === accountId) bucket.transferOut += share; + } } } for (const row of buckets.values()) { @@ -250,7 +273,7 @@ function personFlowStats(data, accountId = 'all', personId = 'all') { } function PersonFlowOverview({stats}){ - return

Personenbezogene Zahlungsströme

{stats.isAll?'Wirtschaftliche Sicht pro Person. Interne Transfers werden separat gezeigt und verändern den Saldo nicht.':'Liquiditätssicht pro Person auf dem gewählten Konto. Transfers werden als echte Zu- und Abflüsse berücksichtigt.'}

=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat
+ return

Personenbezogene Zahlungsströme

{stats.isAll?'Wirtschaftliche Sicht pro Person. Gemeinsam zugeordnete Bewegungen werden gleichmäßig geteilt; interne Transfers bleiben separat und saldoneutral.':'Liquiditätssicht pro Person auf dem gewählten Konto. Gemeinsam zugeordnete Bewegungen werden gleichmäßig geteilt; Transfers zählen als echte Zu- und Abflüsse.'}

=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat
{stats.rows.length===0?:
{stats.rows.map(row=>
{row.name}{stats.isAll?'wirtschaftlicher Monatssaldo':'Nettofluss auf diesem Konto'}
=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}
{stats.isAll?<>
Einnahmen+{formatMoney(row.income)}
Ausgaben−{formatMoney(row.expense)}
Interne Transfers{formatMoney(row.transferInternal)}
Saldo=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}
:<>
Zuflüsse+{formatMoney(row.displayIn)}
Abflüsse−{formatMoney(row.displayOut)}
Transfers rein+{formatMoney(row.transferIn)}
Transfers raus−{formatMoney(row.transferOut)}
}
)}
}
; } @@ -288,7 +311,7 @@ function scenarioData(data, scenario) { if (!scenario) return data; const next = { ...data, incomes:[...data.incomes], expenses:[...data.expenses], transfers:[...data.transfers] }; for (const a of scenario.adjustments || []) { - const common = { id:`scenario-${scenario.id}-${a.id}`, name:a.name, amount:Number(a.amount||0), interval:'monthly', dueMonth:null, categoryId:a.categoryId || null, personId:a.personId || null }; + const common = { id:`scenario-${scenario.id}-${a.id}`, name:a.name, amount:Number(a.amount||0), interval:'monthly', dueMonth:null, categoryId:a.categoryId || null, personIds:personIdsOf(a) }; if (a.type === 'income') next.incomes.push({...common,accountId:a.accountId}); if (a.type === 'expense') next.expenses.push({...common,accountId:a.accountId}); if (a.type === 'transfer') next.transfers.push({...common,fromAccountId:a.fromAccountId,toAccountId:a.toAccountId}); @@ -364,7 +387,20 @@ function App() { function removeAccount(id){const used=data.incomes.some(x=>x.accountId===id)||data.expenses.some(x=>x.accountId===id)||data.transfers.some(x=>x.fromAccountId===id||x.toAccountId===id)||(data.scenarios||[]).some(s=>(s.adjustments||[]).some(x=>x.accountId===id||x.fromAccountId===id||x.toAccountId===id));if(used)return setError('Das Konto wird noch verwendet. Einträge, Transfers oder Szenario-Anpassungen bitte zuerst löschen.');if(confirm('Konto wirklich löschen?'))persist({...data,accounts:data.accounts.filter(x=>x.id!==id)});} function removeItem(type,id){if(confirm('Eintrag wirklich löschen?'))persist({...data,[type]:data[type].filter(x=>x.id!==id)});} function removeCategory(id){const used=[...data.incomes,...data.expenses,...data.transfers].filter(x=>x.categoryId===id).length;const message=used?`Kategorie wird bei ${used} ${used===1?'Eintrag':'Einträgen'} entfernt. Kategorie wirklich löschen?`:'Kategorie wirklich löschen?';if(!confirm(message))return;persist({...data,categories:data.categories.filter(x=>x.id!==id),incomes:data.incomes.map(x=>x.categoryId===id?{...x,categoryId:null}:x),expenses:data.expenses.map(x=>x.categoryId===id?{...x,categoryId:null}:x),transfers:data.transfers.map(x=>x.categoryId===id?{...x,categoryId:null}:x),scenarios:(data.scenarios||[]).map(s=>({...s,adjustments:(s.adjustments||[]).map(x=>x.categoryId===id?{...x,categoryId:null}:x)}))});} - function removePerson(id){const used=[...data.incomes,...data.expenses,...data.transfers].filter(x=>x.personId===id).length+(data.scenarios||[]).reduce((sum,s)=>sum+(s.adjustments||[]).filter(x=>x.personId===id).length,0);const message=used?`Person wird bei ${used} ${used===1?'Bewegung':'Bewegungen'} entfernt. Zuordnung wirklich löschen?`:'Person wirklich löschen?';if(!confirm(message))return;persist({...data,persons:(data.persons||[]).filter(x=>x.id!==id),incomes:data.incomes.map(x=>x.personId===id?{...x,personId:null}:x),expenses:data.expenses.map(x=>x.personId===id?{...x,personId:null}:x),transfers:data.transfers.map(x=>x.personId===id?{...x,personId:null}:x),scenarios:(data.scenarios||[]).map(s=>({...s,adjustments:(s.adjustments||[]).map(x=>x.personId===id?{...x,personId:null}:x)}))});} + function removePerson(id){ + const all=[...data.incomes,...data.expenses,...data.transfers]; + const used=all.filter(x=>personIdsOf(x).includes(id)).length+(data.scenarios||[]).reduce((sum,s)=>sum+(s.adjustments||[]).filter(x=>personIdsOf(x).includes(id)).length,0); + const message=used?`Person wird bei ${used} ${used===1?'Bewegung':'Bewegungen'} aus der Zuordnung entfernt. Wirklich löschen?`:'Person wirklich löschen?'; + if(!confirm(message))return; + const without=idList=>(Array.isArray(idList)?idList:[]).filter(x=>x!==id); + persist({...data, + persons:(data.persons||[]).filter(x=>x.id!==id), + incomes:data.incomes.map(x=>({...x,personIds:without(personIdsOf(x))})), + expenses:data.expenses.map(x=>({...x,personIds:without(personIdsOf(x))})), + transfers:data.transfers.map(x=>({...x,personIds:without(personIdsOf(x))})), + scenarios:(data.scenarios||[]).map(s=>({...s,adjustments:(s.adjustments||[]).map(x=>({...x,personIds:without(personIdsOf(x))}))})) + }); + } async function importFile(file){try{await persist(JSON.parse(await file.text()));selectTab('dashboard');}catch(e){setError(`Import fehlgeschlagen: ${e.message}`);}} async function togglePeriodic(){await persist({...data,settings:{...data.settings,includePeriodicInBalance:!(data.settings?.includePeriodicInBalance!==false)}});} function selectTab(next){setTab(next);setNavMenu(null);} @@ -472,12 +508,22 @@ function Metric({label,value,positive,negative,featured}){return
{label}{value}{hint&&{hint}}
} function CountMetric({label,value}){return
{label}{Number(value||0).toLocaleString('de-DE')}
} +function PersonMultiSelect({persons,selectedIds,onChange}){ + const selected=new Set(selectedIds||[]); + const toggle=id=>onChange(selected.has(id)?[...selected].filter(x=>x!==id):[...selected,id]); + return
+ Personen + {(persons||[]).length===0?Noch keine Personen angelegt.:
{persons.map(person=>{const active=selected.has(person.id);return })}
} + Mehrere Personen möglich. In personenbezogenen Auswertungen wird der Betrag gleichmäßig auf alle ausgewählten Personen verteilt. +
; +} + function Accounts({data,onEdit,onDelete,onAdd,onAddPerson,onEditPerson,onDeletePerson}){ - const personUsage=id=>data.incomes.filter(x=>x.personId===id).length+data.expenses.filter(x=>x.personId===id).length+data.transfers.filter(x=>x.personId===id).length+(data.scenarios||[]).reduce((sum,s)=>sum+(s.adjustments||[]).filter(x=>x.personId===id).length,0); - const unassigned=[...data.incomes,...data.expenses,...data.transfers].filter(x=>!x.personId).length; + const personUsage=id=>data.incomes.filter(x=>personIdsOf(x).includes(id)).length+data.expenses.filter(x=>personIdsOf(x).includes(id)).length+data.transfers.filter(x=>personIdsOf(x).includes(id)).length+(data.scenarios||[]).reduce((sum,s)=>sum+(s.adjustments||[]).filter(x=>personIdsOf(x).includes(id)).length,0); + const unassigned=[...data.incomes,...data.expenses,...data.transfers].filter(x=>personIdsOf(x).length===0).length; return

Konten

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

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

Personen

Du kannst beliebig viele Personen hinterlegen. Jede Bewegung wird genau einer Person zugeordnet, einschließlich Umbuchungen.

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

Personen

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

{unassigned>0&&
{unassigned} bestehende {unassigned===1?'Bewegung ist':'Bewegungen sind'} noch ohne Person.Diese Altbestände bleiben erhalten und können beim Bearbeiten zugeordnet werden. Neue Bewegungen benötigen mindestens eine Person.
}{(data.persons||[]).length===0?:
{data.persons.map(person=>
{person.name}{personUsage(person.id)} {personUsage(person.id)===1?'zugeordnete Bewegung':'zugeordnete Bewegungen'}
)}
}
; } @@ -494,16 +540,16 @@ function MovementsPage({data,onEdit,onDuplicate,onDelete,onAdd}){ function Entries({type,items,accounts,categories,persons,onEdit,onDuplicate,onDelete}){ const accountNameLocal=id=>accounts.find(a=>a.id===id)?.name||'Unbekannt'; const categoryNameLocal=id=>categories.find(c=>c.id===id)?.name||'Ohne Kategorie'; - const personNameLocal=id=>persons.find(p=>p.id===id)?.name||'Ohne Person'; + const personLabelLocal=item=>{const ids=personIdsOf(item);return ids.length?ids.map(id=>persons.find(p=>p.id===id)?.name||'Unbekannt').join(', '):'Ohne Person';}; const isIncome=type==='incomes'; const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; const currentAmount=x=>effectiveAmount(x); return

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

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

{items.length===0?:<> -
{items.map(x=>)}
BezeichnungPersonKategorieKontoIntervallMonatBetragØ / Monat
{x.name}{!isIncome&&x.pushoverReminder&&Reminder}{(x.amountChanges||[]).length>0&&{x.amountChanges.length} Änderung{x.amountChanges.length===1?'':'en'}}{x.endDate&&bis {formatDate(x.endDate)}}{personNameLocal(x.personId)}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.accountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{isIncome?'+':'−'}{formatMoney(currentAmount(x))}{formatMoney(monthlyEquivalent(x))}
+
{items.map(x=>)}
BezeichnungPersonenKategorieKontoIntervallMonatBetragØ / Monat
{x.name}{!isIncome&&x.pushoverReminder&&Reminder}{(x.amountChanges||[]).length>0&&{x.amountChanges.length} Änderung{x.amountChanges.length===1?'':'en'}}{x.endDate&&bis {formatDate(x.endDate)}}{personLabelLocal(x)}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.accountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{isIncome?'+':'−'}{formatMoney(currentAmount(x))}{formatMoney(monthlyEquivalent(x))}
{items.map(x=>
{x.name}{categoryNameLocal(x.categoryId)}{!isIncome&&x.pushoverReminder&&Reminder am 1.}{(x.amountChanges||[]).length>0&&{x.amountChanges.length} geplant}{x.endDate&&bis {formatDate(x.endDate)}}
{isIncome?'+':'−'}{formatMoney(currentAmount(x))}
-
Person{personNameLocal(x.personId)}
+
Personen{personLabelLocal(x)}
Konto{accountNameLocal(x.accountId)}
Intervall{intervals[x.interval||'monthly'].label}
Fälligkeit{dueText(x)}
@@ -517,15 +563,15 @@ function Entries({type,items,accounts,categories,persons,onEdit,onDuplicate,onDe function Transfers({data,onEdit,onDuplicate,onDelete}){ const accountNameLocal=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt'; const categoryNameLocal=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie'; - const personNameLocal=id=>(data.persons||[]).find(p=>p.id===id)?.name||'Ohne Person'; + const personLabelLocal=item=>personListLabel(data,item); const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; return

Fixe Transfers

Verschiebungen zwischen eigenen Konten. Global saldoneutral.

{data.transfers.length===0?:<> -
{data.transfers.map(x=>)}
BezeichnungPersonKategorieVonNachIntervallMonatBetragØ / Monat
{x.name}{x.endDate&&bis {formatDate(x.endDate)}}{personNameLocal(x.personId)}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.fromAccountId)}{accountNameLocal(x.toAccountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
+
{data.transfers.map(x=>)}
BezeichnungPersonenKategorieVonNachIntervallMonatBetragØ / Monat
{x.name}{x.endDate&&bis {formatDate(x.endDate)}}{personLabelLocal(x)}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.fromAccountId)}{accountNameLocal(x.toAccountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
{data.transfers.map(x=>
{x.name}{categoryNameLocal(x.categoryId)}{x.endDate&&bis {formatDate(x.endDate)}}
{formatMoney(x.amount)}
Von{accountNameLocal(x.fromAccountId)}
Nach{accountNameLocal(x.toAccountId)}
-
Person{personNameLocal(x.personId)}
+
Personen{personLabelLocal(x)}
Intervall{intervals[x.interval||'monthly'].label}
Fälligkeit{dueText(x)}
Ø / Monat{formatMoney(monthlyEquivalent(x))}
@@ -543,7 +589,7 @@ function CategoryDistribution({title,subtitle,stats,tone,data}){ return `${item._statType==='income'?'Eingang':'Ausgang'} · ${accountName(data,item.accountId)}`; } return

{title}

{subtitle}

{formatMoney(stats.total)} / Monat
- {stats.rows.length===0?:
{stats.rows.map((row,index)=>
{index+1}{row.name}{row.count} {row.count===1?'Position':'Positionen'}
{formatMoney(row.amount)}{row.percent.toFixed(1).replace('.',',')} %
{row.items.sort((a,b)=>b.statAmount-a.statAmount).map((item,i)=>
{item.name}{itemMeta(item)} · {personName(data,item.personId)} · {intervals[item.interval||'monthly'].label}
{formatMoney(item.statAmount)} / Monat
)}
)}
} + {stats.rows.length===0?:
{stats.rows.map((row,index)=>
{index+1}{row.name}{row.count} {row.count===1?'Position':'Positionen'}
{formatMoney(row.amount)}{row.percent.toFixed(1).replace('.',',')} %
{row.items.sort((a,b)=>b.statAmount-a.statAmount).map((item,i)=>
{item.name}{itemMeta(item)} · {personListLabel(data,item)} · {intervals[item.interval||'monthly'].label}
{formatMoney(item.statAmount)} / Monat
)}
)}
}
; } @@ -557,8 +603,8 @@ function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){ const selectedName=data.accounts.find(a=>a.id===accountId)?.name||'Alle Konten'; const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); const usage=id=>data.incomes.filter(x=>x.categoryId===id).length+data.expenses.filter(x=>x.categoryId===id).length+data.transfers.filter(x=>x.categoryId===id).length; - const personMatches=x=>personId==='all'||x.personId===personId; - const relevantEntries=[...data.incomes.filter(x=>personMatches(x)&&(accountId==='all'||x.accountId===accountId)),...data.expenses.filter(x=>personMatches(x)&&(accountId==='all'||x.accountId===accountId)),...data.transfers.filter(x=>personMatches(x)&&(accountId==='all'||x.fromAccountId===accountId||x.toAccountId===accountId))]; + const personMatchesLocal=x=>personMatches(x,personId); + const relevantEntries=[...data.incomes.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.accountId===accountId)),...data.expenses.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.accountId===accountId)),...data.transfers.filter(x=>personMatchesLocal(x)&&(accountId==='all'||x.fromAccountId===accountId||x.toAccountId===accountId))]; const uncategorized=relevantEntries.filter(x=>!x.categoryId).length; return

Gesamtkennzahlen

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

@@ -594,11 +640,11 @@ function YearPage({data}){ const kindLabel=x=>x.kind==='income'?(forecast.isAll?'Eingang':'Echter Eingang'):x.kind==='expense'?(forecast.isAll?'Ausgang':'Echte Ausgabe'):x.direction==='in'?'Transfer rein':x.direction==='out'?'Transfer raus':'Transfer'; const kindSign=x=>x.kind==='income'||x.direction==='in'?'+':x.kind==='expense'||x.direction==='out'?'−':''; const kindTone=x=>x.kind==='income'||x.direction==='in'?'good-text':x.kind==='expense'||x.direction==='out'?'bad-text':''; - const chipLabel=x=>`${x.name} · ${intervals[x.interval||'monthly'].label} · ${kindLabel(x)} · ${formatMoney(x.forecastAmount??x.amount)}`; + const chipLabel=x=>`${x.name} · ${intervals[x.interval||'monthly'].label} · ${kindLabel(x)} · ${formatMoney(x.forecastAmount??(Number(x.amount||0)*personFactor(x,personId)))}`; const maxExpense=Math.max(1,...forecast.rows.map(r=>r.displayExpense)); const maxRow=forecast.rows.reduce((a,b)=>b.displayExpense>a.displayExpense?b:a,forecast.rows[0]); const yearBalance=forecast.yearIncome-forecast.yearExpense; - const detailRow=(x,i)=>
{kindLabel(x)}{x.name}{personName(data,x.personId)}
{kindSign(x)}{formatMoney(x.forecastAmount??x.amount)}
; + const detailRow=(x,i)=>
{kindLabel(x)}{x.name}{personListLabel(data,x)}
{kindSign(x)}{formatMoney(x.forecastAmount??x.amount)}
; return
Jahresansicht für{selectedName} · {selectedPerson} · {year}
@@ -655,7 +701,7 @@ function ScenariosPage({data,persist}){
Szenario{scenarios.length?:Noch keines angelegt}
{scenario&&<>}
{!scenario?
:<>
=0} negative={baseTotals.balance<0}/>=0} negative={projected.balance<0} featured/>=0} negative={projected.balance-baseTotals.balance<0}/>
-

Was-wäre-wenn-Anpassungen

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

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

Was-wäre-wenn-Anpassungen

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

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

Auswirkung je Konto

Vergleich des monatlichen Saldos zwischen Basisplan und Szenario.

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

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

{error&&
{error}
}{type!=='transfer'?:<>}
; + const [name,setName]=useState(item?.name||''); + const [type,setType]=useState(item?.type||'expense'); + const [amount,setAmount]=useState(item?.amount??''); + const [accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''); + const [fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''); + const [toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''); + const [personIds,setPersonIds]=useState(personIdsOf(item)); + const [error,setError]=useState(''); + function submit(e){ + e.preventDefault();const numeric=parseAmount(amount); + if(!name.trim())return setError('Bitte eine Bezeichnung eingeben.'); + if(!Number.isFinite(numeric)||numeric<0)return setError('Bitte einen gültigen Betrag eingeben.'); + if(personIds.length===0)return setError('Bitte mindestens eine Person auswählen.'); + if(type==='transfer'&&fromAccountId===toAccountId)return setError('Quell- und Zielkonto müssen verschieden sein.'); + const value={id:item?.id||uid(),name:name.trim(),type,amount:numeric,personIds:[...new Set(personIds)]}; + if(type==='transfer'){value.fromAccountId=fromAccountId;value.toAccountId=toAccountId;}else value.accountId=accountId; + onSave(value); + } + return
e.target===e.currentTarget&&onClose()}>
+
SZENARIO

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

+ {error&&
{error}
} + + + + + {type!=='transfer'?:<>} +
+
; } function PushoverSettings(){ @@ -694,7 +764,7 @@ function StatementsPage({data}){ useEffect(()=>{if(accountId&&!data.accounts.some(a=>a.id===accountId))setAccountId(data.accounts[0]?.id||'');if(!accountId&&data.accounts[0])setAccountId(data.accounts[0].id);},[data.accounts,accountId]); if(!data.accounts.length)return
; const account=accountName(data,accountId);const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId);const query=`year=${encodeURIComponent(year)}&account=${encodeURIComponent(accountId)}&person=${encodeURIComponent(personId)}`; - return

Kontoauszug erstellen

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

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

PDF: Jahreswerte und monatlich gruppierte Kontobewegungen. Excel: genau zwei Blätter, Kontoauszug und Monatsübersicht.

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

; + return

Kontoauszug erstellen

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

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

PDF: Jahreswerte und monatlich gruppierte Kontobewegungen. Bei Person-Filter werden gemeinsam zugeordnete Bewegungen anteilig ausgewiesen. Excel: genau zwei Blätter, Kontoauszug und Monatsübersicht.

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

; } function DataPage({data,importFile}){ @@ -723,7 +793,24 @@ function Empty({text}){return
{text}
} function EditorDialog({dialog,data,onClose,onSave}){ useModalScrollLock(); const type=dialog.type,item=dialog.item; const editing=Boolean(item?.id); - const [name,setName]=useState(item?.name||''),[amount,setAmount]=useState(item?.amount??''),[accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''),[fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''),[toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''),[interval,setInterval]=useState(item?.interval||'monthly'),[dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''),[categoryId,setCategoryId]=useState(item?.categoryId||''),[newCategoryName,setNewCategoryName]=useState(''),[personId,setPersonId]=useState(item?.personId||''),[newPersonName,setNewPersonName]=useState(''),[pushoverReminder,setPushoverReminder]=useState(item?.pushoverReminder===true),[endDate,setEndDate]=useState(item?.endDate||''),[amountChanges,setAmountChanges]=useState(Array.isArray(item?.amountChanges)?item.amountChanges:[]),[changeDate,setChangeDate]=useState(''),[changeAmount,setChangeAmount]=useState(''),[localError,setLocalError]=useState(''); + const [name,setName]=useState(item?.name||''), + [amount,setAmount]=useState(item?.amount??''), + [accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''), + [fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''), + [toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''), + [interval,setInterval]=useState(item?.interval||'monthly'), + [dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''), + [categoryId,setCategoryId]=useState(item?.categoryId||''), + [newCategoryName,setNewCategoryName]=useState(''), + [personIds,setPersonIds]=useState(personIdsOf(item)), + [newPersonName,setNewPersonName]=useState(''), + [showNewPerson,setShowNewPerson]=useState(false), + [pushoverReminder,setPushoverReminder]=useState(item?.pushoverReminder===true), + [endDate,setEndDate]=useState(item?.endDate||''), + [amountChanges,setAmountChanges]=useState(Array.isArray(item?.amountChanges)?item.amountChanges:[]), + [changeDate,setChangeDate]=useState(''), + [changeAmount,setChangeAmount]=useState(''), + [localError,setLocalError]=useState(''); const labels={accounts:'Konto',categories:'Kategorie',persons:'Person',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'}; function addAmountChange(){const numeric=parseAmount(changeAmount);if(!changeDate)return setLocalError('Bitte ein Datum für die Betragsänderung auswählen.');if(!Number.isFinite(numeric)||numeric<0)return setLocalError('Bitte einen gültigen neuen Betrag eingeben.');if(endDate&&changeDate.slice(0,7)>endDate.slice(0,7))return setLocalError('Die Betragsänderung liegt nach dem Enddatum.');if(amountChanges.some(c=>c.effectiveFrom===changeDate))return setLocalError('Für dieses Datum existiert bereits eine Betragsänderung.');setAmountChanges([...amountChanges,{id:uid(),effectiveFrom:changeDate,amount:numeric}].sort((a,b)=>a.effectiveFrom.localeCompare(b.effectiveFrom)));setChangeDate('');setChangeAmount('');setLocalError('');} async function submit(e){ @@ -731,7 +818,7 @@ function EditorDialog({dialog,data,onClose,onSave}){ if(!name.trim())return setLocalError('Bitte eine Bezeichnung eingeben.'); if(!['accounts','categories','persons'].includes(type)&&(numeric<0||!Number.isFinite(numeric)))return setLocalError('Bitte einen gültigen Betrag eingeben.'); if(!['accounts','categories','persons'].includes(type)&&data.accounts.length===0)return setLocalError('Bitte zuerst ein Konto anlegen.'); - if(['incomes','expenses','transfers'].includes(type)&&!personId)return setLocalError('Bitte eine Person auswählen oder direkt neu anlegen.'); + if(['incomes','expenses','transfers'].includes(type)&&personIds.length===0&&!newPersonName.trim())return setLocalError('Bitte mindestens eine Person auswählen oder direkt neu anlegen.'); if(type==='transfers'&&fromAccountId===toAccountId)return setLocalError('Quell- und Zielkonto müssen verschieden sein.'); if(type==='expenses'&&pushoverReminder&&interval!=='monthly'&&!dueMonth)return setLocalError('Für den Reminder bitte einen Fälligkeitsmonat festlegen.'); if(endDate&&amountChanges.some(c=>c.effectiveFrom.slice(0,7)>endDate.slice(0,7)))return setLocalError('Eine geplante Betragsänderung liegt nach dem Enddatum.'); @@ -743,9 +830,19 @@ function EditorDialog({dialog,data,onClose,onSave}){ const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null),endDate:endDate||null}; let selectedCategoryId=categoryId||null; if(categoryId==='__new__'){const categoryNameValue=newCategoryName.trim();if(!categoryNameValue)return setLocalError('Bitte einen Namen für die neue Kategorie eingeben.');const existing=next.categories.find(c=>c.name.trim().toLocaleLowerCase('de-DE')===categoryNameValue.toLocaleLowerCase('de-DE'));if(existing) selectedCategoryId=existing.id;else { selectedCategoryId=uid(); next.categories.push({id:selectedCategoryId,name:categoryNameValue}); }} - let selectedPersonId=personId||null; - if(personId==='__new__'){const personNameValue=newPersonName.trim();if(!personNameValue)return setLocalError('Bitte einen Namen für die neue Person eingeben.');const existing=(next.persons||[]).find(p=>p.name.trim().toLocaleLowerCase('de-DE')===personNameValue.toLocaleLowerCase('de-DE'));if(existing) selectedPersonId=existing.id;else { selectedPersonId=uid(); next.persons=[...(next.persons||[]),{id:selectedPersonId,name:personNameValue}]; }} - if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId,personId:selectedPersonId}; else if(type==='expenses') value={...common,accountId,categoryId:selectedCategoryId,personId:selectedPersonId,amountChanges,pushoverReminder}; else value={...common,accountId,categoryId:selectedCategoryId,personId:selectedPersonId,amountChanges}; + let selectedPersonIds=[...new Set(personIds)]; + if(newPersonName.trim()){ + const personNameValue=newPersonName.trim(); + const existing=(next.persons||[]).find(p=>p.name.trim().toLocaleLowerCase('de-DE')===personNameValue.toLocaleLowerCase('de-DE')); + let newId; + if(existing)newId=existing.id; + else {newId=uid();next.persons=[...(next.persons||[]),{id:newId,name:personNameValue}];} + if(!selectedPersonIds.includes(newId))selectedPersonIds.push(newId); + } + if(selectedPersonIds.length===0)return setLocalError('Bitte mindestens eine Person auswählen.'); + if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId,personIds:selectedPersonIds}; + else if(type==='expenses') value={...common,accountId,categoryId:selectedCategoryId,personIds:selectedPersonIds,amountChanges,pushoverReminder}; + else value={...common,accountId,categoryId:selectedCategoryId,personIds:selectedPersonIds,amountChanges}; } next[type]=editing?next[type].map(x=>x.id===item.id?value:x):[...next[type],value]; try{await onSave(next);}catch(e){setLocalError(e.message);} @@ -754,7 +851,10 @@ function EditorDialog({dialog,data,onClose,onSave}){ return
e.target===e.currentTarget&&onClose()}>
{dialog.duplicate?'DUPLIZIEREN':editing?'BEARBEITEN':'NEU'}

{labels[type]}

{localError&&
{localError}
} {!simpleType&&<>{interval!=='monthly'&&}} {(type==='incomes'||type==='expenses'||type==='transfers')&&<>{categoryId==='__new__'&&}} - {(type==='incomes'||type==='expenses'||type==='transfers')&&<>{personId==='__new__'&&}} + {(type==='incomes'||type==='expenses'||type==='transfers')&&<> + + {!showNewPerson?:
} + } {(type==='incomes'||type==='expenses')&&} {(type==='incomes'||type==='expenses')&&
Geplante BetragsänderungenEin neuer Betrag gilt ab dem Monat des gewählten Datums. Der Basisbetrag oben bleibt für frühere Monate erhalten.
{amountChanges.length}
{amountChanges.length>0&&
{amountChanges.map(c=>
ab {formatDate(c.effectiveFrom)}{formatMoney(c.amount)}
)}
}
} {type==='expenses'&&} diff --git a/src/styles.css b/src/styles.css index bfa73c2..36cfe09 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1064,3 +1064,26 @@ html.modal-open, body.modal-open { overflow:hidden !important; overscroll-behavi .person-flow-row { padding:10px; } .person-flow-head > strong { font-size:14px; } } + + +/* v3.22: Mehrfach-Personenzuordnung */ +.person-multi-field { display:grid; gap:8px; margin-bottom:14px; } +.person-multi-field > span { color:#aab5c4; font-size:12px; font-weight:700; } +.person-choice-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; } +.person-choice { min-width:0; display:flex; align-items:center; gap:8px; padding:9px 10px; border:1px solid #334158; border-radius:10px; background:#0b111a; color:#b9c4d3; text-align:left; } +.person-choice:hover { border-color:#4a5b76; background:#101825; } +.person-choice.active { border-color:#697cff; background:#1c2340; color:#eef0ff; box-shadow:0 0 0 2px #8293ff12; } +.person-choice .checkbox-mark { width:18px; height:18px; flex:0 0 18px; display:grid; place-items:center; border:1px solid #4a5870; border-radius:5px; font-size:11px; color:#fff; } +.person-choice.active .checkbox-mark { background:#6577e8; border-color:#7f8fff; } +.person-choice strong { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; } +.person-add-inline { width:100%; margin:-3px 0 14px; } +.person-new-field { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; margin-bottom:14px; } +.person-new-field label { margin:0; } +.person-new-field button { min-height:42px; } + +@media (max-width:760px) { + .person-choice-grid { grid-template-columns:1fr 1fr; } + .person-choice { padding:10px; min-height:42px; } + .person-new-field { grid-template-columns:1fr; } + .person-new-field button { width:100%; } +}