This commit is contained in:
2026-08-19 21:11:42 +02:00
parent 4ecbff359e
commit b4d260421b
6 changed files with 248 additions and 96 deletions
+1 -1
View File
@@ -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. - 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. - 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.
+1 -1
View File
@@ -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 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'];
+27 -9
View File
@@ -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 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 validIntervals=new Set(['monthly','quarterly','semiannual','yearly']);
const validScenarioTypes=new Set(['income','expense','transfer']); 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); 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 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 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 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 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),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}; if(type==='transfer') return {...base,fromAccountId:item.fromAccountId,toAccountId:item.toAccountId};
return {...base,accountId:item.accountId}; return {...base,accountId:item.accountId};
} }
@@ -29,7 +37,7 @@ function normalizeScenario(item={}) { return {id:item.id,name:item.name,createdA
function normalizeData(input){ function normalizeData(input){
if(!input||typeof input!=='object')return input; if(!input||typeof input!=='object')return input;
return { return {
version:9, version:10,
settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false}, settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false},
accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts, accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts,
categories:Array.isArray(input.categories)?input.categories.map(({id,name})=>({id,name})):[], 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 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 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 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){ function validateData(input){
if(!input||typeof input!=='object')throw new Error('Ungültiges JSON.'); 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.`); 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);} 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(); 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 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 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}'.`);if(t.personId!==null&&t.personId!==undefined&&!personIds.has(t.personId))throw new Error(`Unbekannte Person beim Transfer '${t.name}'.`);} for(const t of input.transfers){validateMovement(t);if(!accountIds.has(t.fromAccountId)||!accountIds.has(t.toAccountId))throw new Error(`Unbekanntes Konto beim Transfer '${t.name}'.`);if(t.fromAccountId===t.toAccountId)throw new Error('Quell- und Zielkonto müssen verschieden sein.');if(t.categoryId!==null&&t.categoryId!==undefined&&!categoryIds.has(t.categoryId))throw new Error(`Unbekannte Kategorie beim Transfer '${t.name}'.`);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.');
@@ -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(!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(!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(!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.`); } 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}'.`); 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 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}){ async function sendPushover(config,{title,message}){
const body=new URLSearchParams({token:config.token,user:config.user,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{} 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})`); if(!response.ok||payload.status!==1)throw new Error(Array.isArray(payload.errors)?payload.errors.join(' '):`Pushover-API Fehler (${response.status})`);
return payload; 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 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);} 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);}}); 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); 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);
+28 -17
View File
@@ -22,38 +22,49 @@ function effectiveAmount(item,year,monthIndex){
function accountName(data,id){return data.accounts.find(a=>a.id===id)?.name||'Unbekannt';} 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 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 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'){ 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:[]})); 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.incomes||[]){
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});} const factor=personFactor(item,personId); if(!factor||item.accountId!==accountId)continue;
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)){ 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});}
const amount=Number(item.amount||0); }
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.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});}
} }
}
for(const r of rows){r.displayIncome=r.income+r.transferIn;r.displayExpense=r.expense+r.transferOut;r.balance=r.displayIncome-r.displayExpense;} 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)}; 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'){ function occurrenceRows(data,year,accountId,personId='all'){
const personMatches=item=>personId==='all'||item.personId===personId;
const result=[]; const result=[];
const push=(item,kind,monthIndex,direction='')=>{ const push=(item,kind,monthIndex,direction='',factor=1)=>{
const amount=kind==='transfer'?Number(item.amount||0):effectiveAmount(item,year,monthIndex); 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:personName(data,item.personId),category:categoryName(data,item.categoryId),amount}); 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.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||[])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.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||[])if(personMatches(item)&&(item.fromAccountId===accountId||item.toAccountId===accountId))for(let m=0;m<12;m++)if(occursInMonth(item,year,m)){ 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'); if(item.toAccountId===accountId)push(item,'transfer',m,'in',factor);
if(item.fromAccountId===accountId)push(item,'transfer',m,'out'); if(item.fromAccountId===accountId)push(item,'transfer',m,'out',factor);
} }}
const order={income:0,transferIn:1,expense:2,transferOut:3}; 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; 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')); 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 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';} 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}]}); 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'); 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'}};}); 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'}}; 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]); 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())}`); 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.yearIncome<forecast.yearExpense}]); 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.yearIncome<forecast.yearExpense}]);
const cols=[{label:'Art',width:70},{label:'Bezeichnung',width:130},{label:'Person',width:80},{label:'Kategorie',width:90},{label:'Zufluss',width:63,align:'right'},{label:'Abfluss',width:62,align:'right'}]; const cols=[{label:'Art',width:70},{label:'Bezeichnung',width:130},{label:'Personen',width:80},{label:'Kategorie',width:90},{label:'Zufluss',width:63,align:'right'},{label:'Abfluss',width:62,align:'right'}];
for(let monthIndex=0;monthIndex<12;monthIndex++){ for(let monthIndex=0;monthIndex<12;monthIndex++){
const monthRows=rows.filter(r=>r.monthIndex===monthIndex);const month=forecast.rows[monthIndex]; const monthRows=rows.filter(r=>r.monthIndex===monthIndex);const month=forecast.rows[monthIndex];
+165 -65
View File
@@ -10,7 +10,7 @@ const intervals = {
semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 }, semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 },
yearly: { label: 'Jährlich', divisor: 12, step: 12 }, 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 formatMoney(value) { return euro.format(Number(value || 0)); }
function parseAmount(value) { 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 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 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 accountName(data, id) { return data.accounts.find(a => a.id === id)?.name || 'Unbekannt'; }
function calc(data) { function calc(data) {
@@ -112,20 +126,22 @@ function calc(data) {
function annualForecast(data, accountId = 'all', year = new Date().getFullYear(), personId = 'all') { function annualForecast(data, accountId = 'all', year = new Date().getFullYear(), personId = 'all') {
const isAll = !accountId || accountId === '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 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 relevantIncome = item => personMatches(item,personId) && (isAll || item.accountId === accountId);
const relevantExpense = item => personMatches(item) && (isAll || item.accountId === accountId); const relevantExpense = item => personMatches(item,personId) && (isAll || item.accountId === accountId);
const relevantTransfer = item => personMatches(item) && (isAll || item.fromAccountId === accountId || item.toAccountId === accountId); const relevantTransfer = item => personMatches(item,personId) && (isAll || item.fromAccountId === accountId || item.toAccountId === accountId);
for (const item of data.incomes.filter(relevantIncome)) { 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 (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)) { 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)) { 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) { 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 {
@@ -135,9 +151,6 @@ function annualForecast(data, accountId = 'all', year = new Date().getFullYear()
} }
} }
rows.forEach(r => { 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.displayIncome = isAll ? r.income : r.income + r.transferIn;
r.displayExpense = isAll ? r.expense : r.expense + r.transferOut; r.displayExpense = isAll ? r.expense : r.expense + r.transferOut;
r.balance = r.displayIncome - r.displayExpense; 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') { function categoryStats(data, accountId = 'all', personId = 'all') {
const categoryMap = new Map((data.categories || []).map(c => [c.id, c.name])); const categoryMap = new Map((data.categories || []).map(c => [c.id, c.name]));
const isAll = accountId === 'all'; const isAll = accountId === 'all';
const personMatches = item => personId === 'all' || item.personId === personId;
const aggregate = items => { const aggregate = items => {
const buckets = new Map(); const buckets = new Map();
for (const item of items) { for (const item of items) {
const factor=personFactor(item,personId);
if(!factor) continue;
const key = item.categoryId || '__uncategorized__'; const key = item.categoryId || '__uncategorized__';
const name = categoryMap.get(item.categoryId) || 'Ohne Kategorie'; 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 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); 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 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})); 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 }; 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 = [ const incomeItems = [
...data.incomes.filter(x => personMatches(x) && x.accountId === accountId).map(x=>({...x,_statType:'income'})), ...data.incomes.filter(x => personMatches(x,personId) && x.accountId === accountId).map(x=>({...x,_statType:'income'})),
...data.transfers.filter(x => personMatches(x) && x.toAccountId === accountId).map(x=>({...x,_statType:'transfer-in'})), ...data.transfers.filter(x => personMatches(x,personId) && x.toAccountId === accountId).map(x=>({...x,_statType:'transfer-in'})),
]; ];
const expenseItems = [ const expenseItems = [
...data.expenses.filter(x => personMatches(x) && x.accountId === accountId).map(x=>({...x,_statType:'expense'})), ...data.expenses.filter(x => personMatches(x,personId) && x.accountId === accountId).map(x=>({...x,_statType:'expense'})),
...data.transfers.filter(x => personMatches(x) && x.fromAccountId === accountId).map(x=>({...x,_statType:'transfer-out'})), ...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 }; 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, { if (!buckets.has(key)) buckets.set(key, {
id:key, id:key,
name:key===unassignedId?'Ohne Person':personName(data,key), name:key===unassignedId?'Ohne Person':personName(data,key),
income:0, income:0, expense:0, transferIn:0, transferOut:0, transferInternal:0,
expense:0, displayIn:0, displayOut:0, net:0,
transferIn:0,
transferOut:0,
transferInternal:0,
displayIn:0,
displayOut:0,
net:0,
}); });
return buckets.get(key); return buckets.get(key);
}; };
for (const person of data.persons || []) ensure(person.id); 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 || []) { for (const item of data.incomes || []) {
if (!personMatches(item) || (!isAll && item.accountId !== accountId)) continue; if (!isAll && item.accountId !== accountId) continue;
ensure(item.personId).income += monthlyEquivalent(item); const amount=monthlyEquivalent(item);
for(const [id,factor] of splitTargets(item)) ensure(id).income += amount*factor;
} }
for (const item of data.expenses || []) { for (const item of data.expenses || []) {
if (!personMatches(item) || (!isAll && item.accountId !== accountId)) continue; if (!isAll && item.accountId !== accountId) continue;
ensure(item.personId).expense += monthlyEquivalent(item); const amount=monthlyEquivalent(item);
for(const [id,factor] of splitTargets(item)) ensure(id).expense += amount*factor;
} }
for (const item of data.transfers || []) { for (const item of data.transfers || []) {
if (!personMatches(item)) continue;
const amount = monthlyEquivalent(item); const amount = monthlyEquivalent(item);
const bucket = ensure(item.personId); for(const [id,factor] of splitTargets(item)) {
if (isAll) bucket.transferInternal += amount; const bucket = ensure(id); const share=amount*factor;
if (isAll) bucket.transferInternal += share;
else { else {
if (item.toAccountId === accountId) bucket.transferIn += amount; if (item.toAccountId === accountId) bucket.transferIn += share;
if (item.fromAccountId === accountId) bucket.transferOut += amount; if (item.fromAccountId === accountId) bucket.transferOut += share;
}
} }
} }
for (const row of buckets.values()) { for (const row of buckets.values()) {
@@ -250,7 +273,7 @@ function personFlowStats(data, accountId = 'all', personId = 'all') {
} }
function PersonFlowOverview({stats}){ function PersonFlowOverview({stats}){
return <section className="panel person-flow-panel"><div className="section-head"><div><h2>Personenbezogene Zahlungsströme</h2><p>{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.'}</p></div><strong className={`person-flow-total ${stats.totals.net>=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat</strong></div> return <section className="panel person-flow-panel"><div className="section-head"><div><h2>Personenbezogene Zahlungsströme</h2><p>{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.'}</p></div><strong className={`person-flow-total ${stats.totals.net>=0?'good-text':'bad-text'}`}>{stats.totals.net>=0?'+':''}{formatMoney(stats.totals.net)} / Monat</strong></div>
{stats.rows.length===0?<Empty text="Noch keine personenbezogenen Werte für diese Auswahl vorhanden."/>:<div className="person-flow-list">{stats.rows.map(row=><div className="person-flow-row" key={row.id}><div className="person-flow-head"><div><span className={`person-badge ${row.id==='__unassigned__'?'muted-person':''}`}>{row.name}</span><small>{stats.isAll?'wirtschaftlicher Monatssaldo':'Nettofluss auf diesem Konto'}</small></div><strong className={row.net>=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}</strong></div><div className="person-flow-grid">{stats.isAll?<><div><span>Einnahmen</span><strong className="good-text">+{formatMoney(row.income)}</strong></div><div><span>Ausgaben</span><strong className="bad-text">{formatMoney(row.expense)}</strong></div><div><span>Interne Transfers</span><strong>{formatMoney(row.transferInternal)}</strong></div><div><span>Saldo</span><strong className={row.net>=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}</strong></div></>:<><div><span>Zuflüsse</span><strong className="good-text">+{formatMoney(row.displayIn)}</strong></div><div><span>Abflüsse</span><strong className="bad-text">{formatMoney(row.displayOut)}</strong></div><div><span>Transfers rein</span><strong className="good-text">+{formatMoney(row.transferIn)}</strong></div><div><span>Transfers raus</span><strong className="bad-text">{formatMoney(row.transferOut)}</strong></div></>}</div></div>)}</div>} {stats.rows.length===0?<Empty text="Noch keine personenbezogenen Werte für diese Auswahl vorhanden."/>:<div className="person-flow-list">{stats.rows.map(row=><div className="person-flow-row" key={row.id}><div className="person-flow-head"><div><span className={`person-badge ${row.id==='__unassigned__'?'muted-person':''}`}>{row.name}</span><small>{stats.isAll?'wirtschaftlicher Monatssaldo':'Nettofluss auf diesem Konto'}</small></div><strong className={row.net>=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}</strong></div><div className="person-flow-grid">{stats.isAll?<><div><span>Einnahmen</span><strong className="good-text">+{formatMoney(row.income)}</strong></div><div><span>Ausgaben</span><strong className="bad-text">{formatMoney(row.expense)}</strong></div><div><span>Interne Transfers</span><strong>{formatMoney(row.transferInternal)}</strong></div><div><span>Saldo</span><strong className={row.net>=0?'good-text':'bad-text'}>{row.net>=0?'+':''}{formatMoney(row.net)}</strong></div></>:<><div><span>Zuflüsse</span><strong className="good-text">+{formatMoney(row.displayIn)}</strong></div><div><span>Abflüsse</span><strong className="bad-text">{formatMoney(row.displayOut)}</strong></div><div><span>Transfers rein</span><strong className="good-text">+{formatMoney(row.transferIn)}</strong></div><div><span>Transfers raus</span><strong className="bad-text">{formatMoney(row.transferOut)}</strong></div></>}</div></div>)}</div>}
</section>; </section>;
} }
@@ -288,7 +311,7 @@ function scenarioData(data, scenario) {
if (!scenario) return data; if (!scenario) return data;
const next = { ...data, incomes:[...data.incomes], expenses:[...data.expenses], transfers:[...data.transfers] }; const next = { ...data, incomes:[...data.incomes], expenses:[...data.expenses], transfers:[...data.transfers] };
for (const a of scenario.adjustments || []) { 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 === 'income') next.incomes.push({...common,accountId:a.accountId});
if (a.type === 'expense') next.expenses.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}); 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 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 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 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 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)}});} async function togglePeriodic(){await persist({...data,settings:{...data.settings,includePeriodicInBalance:!(data.settings?.includePeriodicInBalance!==false)}});}
function selectTab(next){setTab(next);setNavMenu(null);} function selectTab(next){setTab(next);setNavMenu(null);}
@@ -472,12 +508,22 @@ function Metric({label,value,positive,negative,featured}){return <div className=
function TextMetric({label,value,hint}){return <div className="metric text-metric"><span>{label}</span><strong>{value}</strong>{hint&&<small>{hint}</small>}</div>} function TextMetric({label,value,hint}){return <div className="metric text-metric"><span>{label}</span><strong>{value}</strong>{hint&&<small>{hint}</small>}</div>}
function CountMetric({label,value}){return <div className="metric"><span>{label}</span><strong>{Number(value||0).toLocaleString('de-DE')}</strong></div>} function CountMetric({label,value}){return <div className="metric"><span>{label}</span><strong>{Number(value||0).toLocaleString('de-DE')}</strong></div>}
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 <div className="person-multi-field">
<span>Personen</span>
{(persons||[]).length===0?<small className="field-help">Noch keine Personen angelegt.</small>:<div className="person-choice-grid">{persons.map(person=>{const active=selected.has(person.id);return <button key={person.id} type="button" className={`person-choice ${active?'active':''}`} aria-pressed={active} onClick={()=>toggle(person.id)}><span className="checkbox-mark">{active?'✓':''}</span><strong>{person.name}</strong></button>})}</div>}
<small className="field-help">Mehrere Personen möglich. In personenbezogenen Auswertungen wird der Betrag gleichmäßig auf alle ausgewählten Personen verteilt.</small>
</div>;
}
function Accounts({data,onEdit,onDelete,onAdd,onAddPerson,onEditPerson,onDeletePerson}){ 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 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=>!x.personId).length; const unassigned=[...data.incomes,...data.expenses,...data.transfers].filter(x=>personIdsOf(x).length===0).length;
return <div className="content-stack accounts-people-page"> return <div className="content-stack accounts-people-page">
<section className="panel"><div className="section-head"><div><h2>Konten</h2><p>Konten dienen nur zur Zuordnung. Es gibt keine gespeicherten Kontostände.</p></div><button className="secondary" onClick={onAdd}>+ Konto</button></div>{data.accounts.length===0?<Empty text="Noch keine Konten vorhanden."/>:<div className="table-wrap"><table><thead><tr><th>Konto</th><th></th></tr></thead><tbody>{data.accounts.map(a=><tr key={a.id}><td><strong>{a.name}</strong></td><td className="actions"><button onClick={()=>onEdit(a)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(a.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section> <section className="panel"><div className="section-head"><div><h2>Konten</h2><p>Konten dienen nur zur Zuordnung. Es gibt keine gespeicherten Kontostände.</p></div><button className="secondary" onClick={onAdd}>+ Konto</button></div>{data.accounts.length===0?<Empty text="Noch keine Konten vorhanden."/>:<div className="table-wrap"><table><thead><tr><th>Konto</th><th></th></tr></thead><tbody>{data.accounts.map(a=><tr key={a.id}><td><strong>{a.name}</strong></td><td className="actions"><button onClick={()=>onEdit(a)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(a.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>
<section className="panel"><div className="section-head"><div><h2>Personen</h2><p>Du kannst beliebig viele Personen hinterlegen. Jede Bewegung wird genau einer Person zugeordnet, einschließlich Umbuchungen.</p></div><button className="secondary" onClick={onAddPerson}>+ Person</button></div>{unassigned>0&&<div className="person-unassigned-note"><strong>{unassigned} bestehende {unassigned===1?'Bewegung ist':'Bewegungen sind'} noch ohne Person.</strong><span>Diese Altbestände bleiben erhalten und können beim Bearbeiten zugeordnet werden. Neue Bewegungen benötigen immer eine Person.</span></div>}{(data.persons||[]).length===0?<Empty text="Noch keine Personen angelegt."/>:<div className="category-manager-list">{data.persons.map(person=><div className="category-manager-row" key={person.id}><div><strong>{person.name}</strong><span>{personUsage(person.id)} {personUsage(person.id)===1?'zugeordnete Bewegung':'zugeordnete Bewegungen'}</span></div><div className="actions"><button onClick={()=>onEditPerson(person)}>Bearbeiten</button><button className="danger" onClick={()=>onDeletePerson(person.id)}>Löschen</button></div></div>)}</div>}</section> <section className="panel"><div className="section-head"><div><h2>Personen</h2><p>Du kannst beliebig viele Personen hinterlegen. Jede Bewegung kann einer oder mehreren Personen zugeordnet werden, einschließlich Umbuchungen.</p></div><button className="secondary" onClick={onAddPerson}>+ Person</button></div>{unassigned>0&&<div className="person-unassigned-note"><strong>{unassigned} bestehende {unassigned===1?'Bewegung ist':'Bewegungen sind'} noch ohne Person.</strong><span>Diese Altbestände bleiben erhalten und können beim Bearbeiten zugeordnet werden. Neue Bewegungen benötigen mindestens eine Person.</span></div>}{(data.persons||[]).length===0?<Empty text="Noch keine Personen angelegt."/>:<div className="category-manager-list">{data.persons.map(person=><div className="category-manager-row" key={person.id}><div><strong>{person.name}</strong><span>{personUsage(person.id)} {personUsage(person.id)===1?'zugeordnete Bewegung':'zugeordnete Bewegungen'}</span></div><div className="actions"><button onClick={()=>onEditPerson(person)}>Bearbeiten</button><button className="danger" onClick={()=>onDeletePerson(person.id)}>Löschen</button></div></div>)}</div>}</section>
</div>; </div>;
} }
@@ -494,16 +540,16 @@ function MovementsPage({data,onEdit,onDuplicate,onDelete,onAdd}){
function Entries({type,items,accounts,categories,persons,onEdit,onDuplicate,onDelete}){ function Entries({type,items,accounts,categories,persons,onEdit,onDuplicate,onDelete}){
const accountNameLocal=id=>accounts.find(a=>a.id===id)?.name||'Unbekannt'; const accountNameLocal=id=>accounts.find(a=>a.id===id)?.name||'Unbekannt';
const categoryNameLocal=id=>categories.find(c=>c.id===id)?.name||'Ohne Kategorie'; 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 isIncome=type==='incomes';
const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt';
const currentAmount=x=>effectiveAmount(x); const currentAmount=x=>effectiveAmount(x);
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}</h2><p>Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.</p></div></div>{items.length===0?<Empty text={`Noch keine ${isIncome?'Eingänge':'Ausgänge'} vorhanden.`}/>:<> return <section className="panel movement-list-panel"><div className="section-head"><div><h2>{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}</h2><p>Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.</p></div></div>{items.length===0?<Empty text={`Noch keine ${isIncome?'Eingänge':'Ausgänge'} vorhanden.`}/>:<>
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Person</th><th>Kategorie</th><th>Konto</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{items.map(x=><tr key={x.id}><td><strong>{x.name}</strong>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder</span>}{(x.amountChanges||[]).length>0&&<span className="change-badge">{x.amountChanges.length} Änderung{x.amountChanges.length===1?'':'en'}</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</td><td><span className={`person-badge ${x.personId?'':'muted-person'}`}>{personNameLocal(x.personId)}</span></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className={`number ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':''}{formatMoney(currentAmount(x))}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div> <div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Personen</th><th>Kategorie</th><th>Konto</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{items.map(x=><tr key={x.id}><td><strong>{x.name}</strong>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder</span>}{(x.amountChanges||[]).length>0&&<span className="change-badge">{x.amountChanges.length} Änderung{x.amountChanges.length===1?'':'en'}</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</td><td><span className={`person-badge ${personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className={`number ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':''}{formatMoney(currentAmount(x))}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div>
<div className="movement-mobile-list">{items.map(x=><article className="movement-card" key={x.id}> <div className="movement-mobile-list">{items.map(x=><article className="movement-card" key={x.id}>
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder am 1.</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><strong className={isIncome?'good-text':'bad-text'}>{isIncome?'+':''}{formatMoney(currentAmount(x))}</strong></div> <div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder am 1.</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><strong className={isIncome?'good-text':'bad-text'}>{isIncome?'+':''}{formatMoney(currentAmount(x))}</strong></div>
<div className="movement-card-grid"> <div className="movement-card-grid">
<div><span>Person</span><strong>{personNameLocal(x.personId)}</strong></div> <div><span>Personen</span><strong>{personLabelLocal(x)}</strong></div>
<div><span>Konto</span><strong>{accountNameLocal(x.accountId)}</strong></div> <div><span>Konto</span><strong>{accountNameLocal(x.accountId)}</strong></div>
<div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</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>Fälligkeit</span><strong>{dueText(x)}</strong></div>
@@ -517,15 +563,15 @@ function Entries({type,items,accounts,categories,persons,onEdit,onDuplicate,onDe
function Transfers({data,onEdit,onDuplicate,onDelete}){ function Transfers({data,onEdit,onDuplicate,onDelete}){
const accountNameLocal=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt'; 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 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'; const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt';
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Verschiebungen zwischen eigenen Konten. Global saldoneutral.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<> return <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Verschiebungen zwischen eigenen Konten. Global saldoneutral.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<>
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Person</th><th>Kategorie</th><th>Von</th><th>Nach</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=><tr key={x.id}><td><strong>{x.name}</strong>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</td><td><span className={`person-badge ${x.personId?'':'muted-person'}`}>{personNameLocal(x.personId)}</span></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.fromAccountId)}</td><td>{accountNameLocal(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className="number">{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>)}</tbody></table></div> <div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Personen</th><th>Kategorie</th><th>Von</th><th>Nach</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=><tr key={x.id}><td><strong>{x.name}</strong>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</td><td><span className={`person-badge ${personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.fromAccountId)}</td><td>{accountNameLocal(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className="number">{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>)}</tbody></table></div>
<div className="movement-mobile-list">{data.transfers.map(x=><article className="movement-card transfer-card" key={x.id}> <div className="movement-mobile-list">{data.transfers.map(x=><article className="movement-card transfer-card" key={x.id}>
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div><strong>{formatMoney(x.amount)}</strong></div> <div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div><strong>{formatMoney(x.amount)}</strong></div>
<div className="transfer-route"><div><span>Von</span><strong>{accountNameLocal(x.fromAccountId)}</strong></div><span className="route-arrow"></span><div><span>Nach</span><strong>{accountNameLocal(x.toAccountId)}</strong></div></div> <div className="transfer-route"><div><span>Von</span><strong>{accountNameLocal(x.fromAccountId)}</strong></div><span className="route-arrow"></span><div><span>Nach</span><strong>{accountNameLocal(x.toAccountId)}</strong></div></div>
<div className="movement-card-grid"> <div className="movement-card-grid">
<div><span>Person</span><strong>{personNameLocal(x.personId)}</strong></div> <div><span>Personen</span><strong>{personLabelLocal(x)}</strong></div>
<div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</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>Fälligkeit</span><strong>{dueText(x)}</strong></div>
<div><span>Ø / Monat</span><strong>{formatMoney(monthlyEquivalent(x))}</strong></div> <div><span>Ø / Monat</span><strong>{formatMoney(monthlyEquivalent(x))}</strong></div>
@@ -543,7 +589,7 @@ function CategoryDistribution({title,subtitle,stats,tone,data}){
return `${item._statType==='income'?'Eingang':'Ausgang'} · ${accountName(data,item.accountId)}`; return `${item._statType==='income'?'Eingang':'Ausgang'} · ${accountName(data,item.accountId)}`;
} }
return <section className="panel category-stat-panel"><div className="section-head"><div><h2>{title}</h2><p>{subtitle}</p></div><strong className={`category-total ${tone==='income'?'good-text':tone==='expense'?'bad-text':''}`}>{formatMoney(stats.total)} / Monat</strong></div> return <section className="panel category-stat-panel"><div className="section-head"><div><h2>{title}</h2><p>{subtitle}</p></div><strong className={`category-total ${tone==='income'?'good-text':tone==='expense'?'bad-text':''}`}>{formatMoney(stats.total)} / Monat</strong></div>
{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">{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)} · {personName(data,item.personId)} · {intervals[item.interval||'monthly'].label}</span></div><strong>{formatMoney(item.statAmount)} / Monat</strong></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>{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">{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)} · {intervals[item.interval||'monthly'].label}</span></div><strong>{formatMoney(item.statAmount)} / Monat</strong></div>)}</div></details>)}</div>}
</section>; </section>;
} }
@@ -557,8 +603,8 @@ function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){
const selectedName=data.accounts.find(a=>a.id===accountId)?.name||'Alle Konten'; const selectedName=data.accounts.find(a=>a.id===accountId)?.name||'Alle Konten';
const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId); 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 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 personMatchesLocal=x=>personMatches(x,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 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; const uncategorized=relevantEntries.filter(x=>!x.categoryId).length;
return <div className="content-stack stats-page"> return <div className="content-stack stats-page">
<section className="panel stats-overview-panel"><div className="section-head"><div><h2>Gesamtkennzahlen</h2><p>Die übergeordneten Kennzahlen liegen hier statt auf der Übersicht.</p></div></div><div className="stats-kpi-grid"> <section className="panel stats-overview-panel"><div className="section-head"><div><h2>Gesamtkennzahlen</h2><p>Die übergeordneten Kennzahlen liegen hier statt auf der Übersicht.</p></div></div><div className="stats-kpi-grid">
@@ -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 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 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 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 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 maxRow=forecast.rows.reduce((a,b)=>b.displayExpense>a.displayExpense?b:a,forecast.rows[0]);
const yearBalance=forecast.yearIncome-forecast.yearExpense; const yearBalance=forecast.yearIncome-forecast.yearExpense;
const detailRow=(x,i)=><div className="year-detail-row" key={`${x.id}-${x.direction||x.kind}-${i}`}><div className="year-detail-name"><span className={`year-kind ${x.kind}`}>{kindLabel(x)}</span><strong>{x.name}</strong><small>{personName(data,x.personId)}</small></div><span className={`year-detail-amount ${kindTone(x)}`}>{kindSign(x)}{formatMoney(x.forecastAmount??x.amount)}</span></div>; const detailRow=(x,i)=><div className="year-detail-row" key={`${x.id}-${x.direction||x.kind}-${i}`}><div className="year-detail-name"><span className={`year-kind ${x.kind}`}>{kindLabel(x)}</span><strong>{x.name}</strong><small>{personListLabel(data,x)}</small></div><span className={`year-detail-amount ${kindTone(x)}`}>{kindSign(x)}{formatMoney(x.forecastAmount??x.amount)}</span></div>;
return <div className="content-stack year-page"> return <div className="content-stack year-page">
<section className="year-account-filter"><div className="year-filter-copy"><span>Jahresansicht für</span><strong>{selectedName} · {selectedPerson} · {year}</strong></div><div className="year-filter-controls"><label><span>Jahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(y=><option key={y} value={y}>{y}</option>)}</select></label><label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Person auswählen</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div></section> <section className="year-account-filter"><div className="year-filter-copy"><span>Jahresansicht für</span><strong>{selectedName} · {selectedPerson} · {year}</strong></div><div className="year-filter-controls"><label><span>Jahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(y=><option key={y} value={y}>{y}</option>)}</select></label><label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Person auswählen</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div></section>
@@ -655,7 +701,7 @@ function ScenariosPage({data,persist}){
<section className="scenario-toolbar"><div><span>Szenario</span>{scenarios.length?<select value={scenario?.id||''} onChange={e=>setSelectedId(e.target.value)}>{scenarios.map(s=><option key={s.id} value={s.id}>{s.name}</option>)}</select>:<strong>Noch keines angelegt</strong>}</div><div className="button-row"><button className="primary" onClick={addScenario}>+ Szenario</button>{scenario&&<><button className="secondary" onClick={renameScenario}>Umbenennen</button><button className="secondary danger-button" onClick={deleteScenario}>Löschen</button></>}</div></section> <section className="scenario-toolbar"><div><span>Szenario</span>{scenarios.length?<select value={scenario?.id||''} onChange={e=>setSelectedId(e.target.value)}>{scenarios.map(s=><option key={s.id} value={s.id}>{s.name}</option>)}</select>:<strong>Noch keines angelegt</strong>}</div><div className="button-row"><button className="primary" onClick={addScenario}>+ Szenario</button>{scenario&&<><button className="secondary" onClick={renameScenario}>Umbenennen</button><button className="secondary danger-button" onClick={deleteScenario}>Löschen</button></>}</div></section>
{!scenario?<section className="panel"><Empty text="Lege ein Szenario an. Es verwendet deinen aktuellen Plan als Basis und ergänzt nur die simulierten Änderungen."/></section>:<> {!scenario?<section className="panel"><Empty text="Lege ein Szenario an. Es verwendet deinen aktuellen Plan als Basis und ergänzt nur die simulierten Änderungen."/></section>:<>
<section className="summary-grid scenario-summary"><Metric label="Aktuell frei / Monat" value={baseTotals.balance} positive={baseTotals.balance>=0} negative={baseTotals.balance<0}/><Metric label="Im Szenario frei / Monat" value={projected.balance} positive={projected.balance>=0} negative={projected.balance<0} featured/><Metric label="Veränderung" value={projected.balance-baseTotals.balance} positive={projected.balance-baseTotals.balance>=0} negative={projected.balance-baseTotals.balance<0}/><CountMetric label="Anpassungen" value={(scenario.adjustments||[]).length}/></section> <section className="summary-grid scenario-summary"><Metric label="Aktuell frei / Monat" value={baseTotals.balance} positive={baseTotals.balance>=0} negative={baseTotals.balance<0}/><Metric label="Im Szenario frei / Monat" value={projected.balance} positive={projected.balance>=0} negative={projected.balance<0} featured/><Metric label="Veränderung" value={projected.balance-baseTotals.balance} positive={projected.balance-baseTotals.balance>=0} negative={projected.balance-baseTotals.balance<0}/><CountMetric label="Anpassungen" value={(scenario.adjustments||[]).length}/></section>
<section className="panel"><div className="section-head"><div><h2>Was-wäre-wenn-Anpassungen</h2><p>Der Basisplan bleibt unverändert. Hier simulierst du zusätzliche Eingänge, Ausgänge oder Transfers.</p></div><button className="secondary" onClick={()=>setAdjustmentDialog({})}>+ Anpassung</button></div>{(scenario.adjustments||[]).length===0?<Empty text="Noch keine Anpassungen in diesem Szenario."/>:<div className="scenario-adjustments">{scenario.adjustments.map(a=><div className="scenario-adjustment" key={a.id}><div><span className={`scenario-type ${a.type}`}>{a.type==='income'?'Eingang':a.type==='expense'?'Ausgang':'Transfer'}</span><strong>{a.name}</strong><small>{a.type==='transfer'?`${accountName(data,a.fromAccountId)}${accountName(data,a.toAccountId)}`:accountName(data,a.accountId)} · {personName(data,a.personId)}</small></div><div><strong className={a.type==='income'?'good-text':a.type==='expense'?'bad-text':''}>{a.type==='income'?'+':a.type==='expense'?'':''}{formatMoney(a.amount)} / Monat</strong><div className="actions"><button onClick={()=>setAdjustmentDialog({item:a})}>Bearbeiten</button><button className="danger" onClick={()=>deleteAdjustment(a.id)}>Löschen</button></div></div></div>)}</div>}</section> <section className="panel"><div className="section-head"><div><h2>Was-wäre-wenn-Anpassungen</h2><p>Der Basisplan bleibt unverändert. Hier simulierst du zusätzliche Eingänge, Ausgänge oder Transfers.</p></div><button className="secondary" onClick={()=>setAdjustmentDialog({})}>+ Anpassung</button></div>{(scenario.adjustments||[]).length===0?<Empty text="Noch keine Anpassungen in diesem Szenario."/>:<div className="scenario-adjustments">{scenario.adjustments.map(a=><div className="scenario-adjustment" key={a.id}><div><span className={`scenario-type ${a.type}`}>{a.type==='income'?'Eingang':a.type==='expense'?'Ausgang':'Transfer'}</span><strong>{a.name}</strong><small>{a.type==='transfer'?`${accountName(data,a.fromAccountId)}${accountName(data,a.toAccountId)}`:accountName(data,a.accountId)} · {personListLabel(data,a)}</small></div><div><strong className={a.type==='income'?'good-text':a.type==='expense'?'bad-text':''}>{a.type==='income'?'+':a.type==='expense'?'':''}{formatMoney(a.amount)} / Monat</strong><div className="actions"><button onClick={()=>setAdjustmentDialog({item:a})}>Bearbeiten</button><button className="danger" onClick={()=>deleteAdjustment(a.id)}>Löschen</button></div></div></div>)}</div>}</section>
<section className="panel"><div className="section-head"><div><h2>Auswirkung je Konto</h2><p>Vergleich des monatlichen Saldos zwischen Basisplan und Szenario.</p></div></div><div className="scenario-account-grid">{projected.accounts.map(p=>{const b=baseTotals.accounts.find(x=>x.account.id===p.account.id);const diff=p.balance-(b?.balance||0);return <div className="scenario-account" key={p.account.id}><strong>{p.account.name}</strong><div><span>Aktuell</span><b>{formatMoney(b?.balance||0)}</b></div><div><span>Szenario</span><b>{formatMoney(p.balance)}</b></div><div><span>Änderung</span><b className={diff>=0?'good-text':'bad-text'}>{diff>=0?'+':''}{formatMoney(diff)}</b></div></div>})}</div></section> <section className="panel"><div className="section-head"><div><h2>Auswirkung je Konto</h2><p>Vergleich des monatlichen Saldos zwischen Basisplan und Szenario.</p></div></div><div className="scenario-account-grid">{projected.accounts.map(p=>{const b=baseTotals.accounts.find(x=>x.account.id===p.account.id);const diff=p.balance-(b?.balance||0);return <div className="scenario-account" key={p.account.id}><strong>{p.account.name}</strong><div><span>Aktuell</span><b>{formatMoney(b?.balance||0)}</b></div><div><span>Szenario</span><b>{formatMoney(p.balance)}</b></div><div><span>Änderung</span><b className={diff>=0?'good-text':'bad-text'}>{diff>=0?'+':''}{formatMoney(diff)}</b></div></div>})}</div></section>
</>} </>}
{adjustmentDialog&&scenario&&<ScenarioAdjustmentDialog data={data} item={adjustmentDialog.item} onClose={()=>setAdjustmentDialog(null)} onSave={saveAdjustment}/>} {adjustmentDialog&&scenario&&<ScenarioAdjustmentDialog data={data} item={adjustmentDialog.item} onClose={()=>setAdjustmentDialog(null)} onSave={saveAdjustment}/>}
@@ -664,10 +710,34 @@ function ScenariosPage({data,persist}){
function ScenarioAdjustmentDialog({data,item,onClose,onSave}){ function ScenarioAdjustmentDialog({data,item,onClose,onSave}){
useModalScrollLock(); useModalScrollLock();
const [name,setName]=useState(item?.name||''); const [type,setType]=useState(item?.type||'expense'); const [amount,setAmount]=useState(item?.amount??''); const [name,setName]=useState(item?.name||'');
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(''); const [type,setType]=useState(item?.type||'expense');
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);} const [amount,setAmount]=useState(item?.amount??'');
return <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}><div className="modal-head"><div><span className="eyebrow">SZENARIO</span><h2>{item?'Anpassung bearbeiten':'Anpassung hinzufügen'}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>{error&&<div className="alert">{error}</div>}<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder="z. B. Neue Wohnung"/></label><label><span>Art</span><select value={type} onChange={e=>setType(e.target.value)}><option value="income">Eingang</option><option value="expense">Ausgang</option><option value="transfer">Transfer</option></select></label><label><span>Betrag / Monat</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span></span></div></label><label><span>Person</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="">Person auswählen </option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label>{type!=='transfer'?<label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>:<><label><span>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 className="primary" type="submit">Speichern</button></div></form></div>; 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 <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}>
<div className="modal-head"><div><span className="eyebrow">SZENARIO</span><h2>{item?'Anpassung bearbeiten':'Anpassung hinzufügen'}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>
{error&&<div className="alert">{error}</div>}
<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder="z. B. Neue Wohnung"/></label>
<label><span>Art</span><select value={type} onChange={e=>setType(e.target.value)}><option value="income">Eingang</option><option value="expense">Ausgang</option><option value="transfer">Transfer</option></select></label>
<label><span>Betrag / Monat</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span></span></div></label>
<PersonMultiSelect persons={data.persons||[]} selectedIds={personIds} onChange={setPersonIds}/>
{type!=='transfer'?<label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>:<><label><span>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 className="primary" type="submit">Speichern</button></div>
</form></div>;
} }
function PushoverSettings(){ 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]); useEffect(()=>{if(accountId&&!data.accounts.some(a=>a.id===accountId))setAccountId(data.accounts[0]?.id||'');if(!accountId&&data.accounts[0])setAccountId(data.accounts[0].id);},[data.accounts,accountId]);
if(!data.accounts.length)return <div className="content-stack statements-page"><section className="panel"><Empty text="Lege zuerst ein Konto an, um einen Kontoauszug zu erstellen."/></section></div>; if(!data.accounts.length)return <div className="content-stack statements-page"><section className="panel"><Empty text="Lege zuerst ein Konto an, um einen Kontoauszug zu erstellen."/></section></div>;
const account=accountName(data,accountId);const selectedPerson=personId==='all'?'Alle Personen':personName(data,personId);const query=`year=${encodeURIComponent(year)}&account=${encodeURIComponent(accountId)}&person=${encodeURIComponent(personId)}`; 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 <div className="content-stack statements-page"><section className="panel report-export-panel"><div className="section-head"><div><h2>Kontoauszug erstellen</h2><p>Nur die geplanten Bewegungen des gewählten Kontos. Keine Stammdaten, Szenarien oder Systemeinstellungen.</p></div><span className="report-badge">PDF · XLSX</span></div><div className="report-controls"><label><span>Jahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(y=><option key={y} value={y}>{y}</option>)}</select></label><label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Person</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div><div className="report-preview"><div><span>Kontoauszug</span><strong>{account} · {selectedPerson} · {year}</strong></div><p>PDF: Jahreswerte und monatlich gruppierte Kontobewegungen. Excel: genau zwei Blätter, Kontoauszug und Monatsübersicht.</p></div><div className="button-row report-actions"><a className="primary button-link" href={`/api/report.pdf?${query}`}>PDF herunterladen</a><a className="secondary button-link" href={`/api/report.xlsx?${query}`}>Excel herunterladen</a></div><p className="report-disclaimer">Erstellt aus FixFin-Planungsdaten, kein von einer Bank ausgestellter Kontoauszug.</p></section></div>; return <div className="content-stack statements-page"><section className="panel report-export-panel"><div className="section-head"><div><h2>Kontoauszug erstellen</h2><p>Nur die geplanten Bewegungen des gewählten Kontos. Keine Stammdaten, Szenarien oder Systemeinstellungen.</p></div><span className="report-badge">PDF · XLSX</span></div><div className="report-controls"><label><span>Jahr</span><select value={year} onChange={e=>setYear(Number(e.target.value))}>{Array.from({length:7},(_,i)=>currentYear-2+i).map(y=><option key={y} value={y}>{y}</option>)}</select></label><label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Person</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="all">Alle Personen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label></div><div className="report-preview"><div><span>Kontoauszug</span><strong>{account} · {selectedPerson} · {year}</strong></div><p>PDF: Jahreswerte und monatlich gruppierte Kontobewegungen. Bei Person-Filter werden gemeinsam zugeordnete Bewegungen anteilig ausgewiesen. Excel: genau zwei Blätter, Kontoauszug und Monatsübersicht.</p></div><div className="button-row report-actions"><a className="primary button-link" href={`/api/report.pdf?${query}`}>PDF herunterladen</a><a className="secondary button-link" href={`/api/report.xlsx?${query}`}>Excel herunterladen</a></div><p className="report-disclaimer">Erstellt aus FixFin-Planungsdaten, kein von einer Bank ausgestellter Kontoauszug.</p></section></div>;
} }
function DataPage({data,importFile}){ function DataPage({data,importFile}){
@@ -723,7 +793,24 @@ function Empty({text}){return <div className="empty">{text}</div>}
function EditorDialog({dialog,data,onClose,onSave}){ function EditorDialog({dialog,data,onClose,onSave}){
useModalScrollLock(); useModalScrollLock();
const type=dialog.type,item=dialog.item; const editing=Boolean(item?.id); 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'}; 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('');} 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){ async function submit(e){
@@ -731,7 +818,7 @@ function EditorDialog({dialog,data,onClose,onSave}){
if(!name.trim())return setLocalError('Bitte eine Bezeichnung eingeben.'); 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)&&(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(!['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==='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(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.'); 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}; const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null),endDate:endDate||null};
let selectedCategoryId=categoryId||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}); }} 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; let selectedPersonIds=[...new Set(personIds)];
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(newPersonName.trim()){
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}; 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]; 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);} try{await onSave(next);}catch(e){setLocalError(e.message);}
@@ -754,7 +851,10 @@ function EditorDialog({dialog,data,onClose,onSave}){
return <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}><div className="modal-head"><div><span className="eyebrow">{dialog.duplicate?'DUPLIZIEREN':editing?'BEARBEITEN':'NEU'}</span><h2>{labels[type]}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>{localError&&<div className="alert">{localError}</div>}<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder={type==='accounts'?'z. B. Girokonto':type==='categories'?'z. B. Wohnen':type==='persons'?'z. B. Michael':'z. B. Kfz-Versicherung'}/></label> return <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}><div className="modal-head"><div><span className="eyebrow">{dialog.duplicate?'DUPLIZIEREN':editing?'BEARBEITEN':'NEU'}</span><h2>{labels[type]}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>{localError&&<div className="alert">{localError}</div>}<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder={type==='accounts'?'z. B. Girokonto':type==='categories'?'z. B. Wohnen':type==='persons'?'z. B. Michael':'z. B. Kfz-Versicherung'}/></label>
{!simpleType&&<><label><span>Betrag pro Intervall</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span></span></div></label><label><span>Intervall</span><select value={interval} onChange={e=>setInterval(e.target.value)}>{Object.entries(intervals).map(([key,x])=><option key={key} value={key}>{x.label}</option>)}</select></label>{interval!=='monthly'&&<label><span>{interval==='yearly'?'Monat der Fälligkeit (optional)':'Erster Fälligkeitsmonat (optional)'}</span><select value={dueMonth} onChange={e=>setDueMonth(e.target.value)}><option value="">Nicht festgelegt</option>{months.map((m,i)=><option key={m} value={i+1}>{m}</option>)}</select><small className="field-help">{interval==='quarterly'?'Ab diesem Monat alle 3 Monate.':interval==='semiannual'?'Ab diesem Monat alle 6 Monate.':'Für die Jahresprognose.'}</small></label>}<label><span>Enddatum / Laufzeit (optional)</span><input type="date" value={endDate} onChange={e=>setEndDate(e.target.value)}/><small className="field-help">Der Monat des Enddatums wird noch vollständig eingeplant.</small></label></>} {!simpleType&&<><label><span>Betrag pro Intervall</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span></span></div></label><label><span>Intervall</span><select value={interval} onChange={e=>setInterval(e.target.value)}>{Object.entries(intervals).map(([key,x])=><option key={key} value={key}>{x.label}</option>)}</select></label>{interval!=='monthly'&&<label><span>{interval==='yearly'?'Monat der Fälligkeit (optional)':'Erster Fälligkeitsmonat (optional)'}</span><select value={dueMonth} onChange={e=>setDueMonth(e.target.value)}><option value="">Nicht festgelegt</option>{months.map((m,i)=><option key={m} value={i+1}>{m}</option>)}</select><small className="field-help">{interval==='quarterly'?'Ab diesem Monat alle 3 Monate.':interval==='semiannual'?'Ab diesem Monat alle 6 Monate.':'Für die Jahresprognose.'}</small></label>}<label><span>Enddatum / Laufzeit (optional)</span><input type="date" value={endDate} onChange={e=>setEndDate(e.target.value)}/><small className="field-help">Der Monat des Enddatums wird noch vollständig eingeplant.</small></label></>}
{(type==='incomes'||type==='expenses'||type==='transfers')&&<><label><span>Kategorie</span><select value={categoryId} onChange={e=>setCategoryId(e.target.value)}><option value="">Ohne Kategorie</option>{data.categories.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}<option value="__new__">+ Neue Kategorie </option></select></label>{categoryId==='__new__'&&<label className="new-category-field"><span>Neue Kategorie</span><input value={newCategoryName} onChange={e=>setNewCategoryName(e.target.value)} placeholder="z. B. Sparen"/></label>}</>} {(type==='incomes'||type==='expenses'||type==='transfers')&&<><label><span>Kategorie</span><select value={categoryId} onChange={e=>setCategoryId(e.target.value)}><option value="">Ohne Kategorie</option>{data.categories.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}<option value="__new__">+ Neue Kategorie </option></select></label>{categoryId==='__new__'&&<label className="new-category-field"><span>Neue Kategorie</span><input value={newCategoryName} onChange={e=>setNewCategoryName(e.target.value)} placeholder="z. B. Sparen"/></label>}</>}
{(type==='incomes'||type==='expenses'||type==='transfers')&&<><label><span>Person</span><select value={personId} onChange={e=>setPersonId(e.target.value)}><option value="">Person auswählen </option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}<option value="__new__">+ Neue Person </option></select></label>{personId==='__new__'&&<label className="new-category-field"><span>Neue Person</span><input value={newPersonName} onChange={e=>setNewPersonName(e.target.value)} placeholder="z. B. Michael"/></label>}</>} {(type==='incomes'||type==='expenses'||type==='transfers')&&<>
<PersonMultiSelect persons={data.persons||[]} selectedIds={personIds} onChange={setPersonIds}/>
{!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')&&<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')&&<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>}
+23
View File
@@ -1064,3 +1064,26 @@ html.modal-open, body.modal-open { overflow:hidden !important; overscroll-behavi
.person-flow-row { padding:10px; } .person-flow-row { padding:10px; }
.person-flow-head > strong { font-size:14px; } .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%; }
}