This commit is contained in:
2026-08-20 19:22:46 +02:00
parent 647cbbbdd0
commit 686b3da3cf
5 changed files with 67 additions and 24 deletions
+4
View File
@@ -138,3 +138,7 @@ Die Hauptnavigation bleibt bei fünf Punkten, die fachlichen Unterseiten sind ab
- **Mehr → Einstellungen & Daten**: Benachrichtigungen sowie Backup/Import und Datenstand auf getrennten Tabs.
Die Kategorienverwaltung wurde vollständig aus der Statistik entfernt. Kategorieauswertungen bleiben dort erhalten. Der bisherige reine Kategorie-Zähler in der Statistik wurde durch einen zur aktuellen Konto-/Personenauswahl passenden Saldo- bzw. Nettoflusswert ersetzt. Die Änderung betrifft nur Navigation und UI-Struktur; das JSON-Datenmodell bleibt bei `version: 11`.
### v3.30 einmalige geplante Positionen
Eingänge, Ausgänge und Transfers unterstützen zusätzlich den Intervalltyp **Einmalig**. Dafür wird ein konkretes Datum (`oneTimeDate`) hinterlegt. Die Position wird nur im betreffenden Monat der Jahresplanung, Statistiken und Kontoauszüge berücksichtigt und nicht auf einen Monatsdurchschnitt verteilt. Einmalige Ausgaben können wie andere Ausgaben per Pushover im Zielmonat erinnert werden.
+1 -1
View File
@@ -1,4 +1,4 @@
const SHELL_CACHE='fixfin-shell-v3.29';
const SHELL_CACHE='fixfin-shell-v3.30';
const DATA_CACHE='fixfin-data-v1';
const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png'];
+7 -6
View File
@@ -9,14 +9,14 @@ const __filename=fileURLToPath(import.meta.url); const __dirname=path.dirname(__
const port=Number(process.env.PORT||3000); const dataFile=process.env.DATA_FILE||'/app/data/finance.json'; const dataDir=path.dirname(dataFile); const backupFile=`${dataFile}.bak`;
const pushoverFile=process.env.PUSHOVER_FILE||path.join(dataDir,'pushover.json'); const pushoverStateFile=process.env.PUSHOVER_STATE_FILE||path.join(dataDir,'pushover-state.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','once']);
const validScenarioTypes=new Set(['income','expense','transfer']);
const validAccountOwnerships=new Set(['personal','shared','unassigned']);
const validAccountFunctions=new Set(['giro','reserve','investment','building','other']);
const emptyData=()=>({version:11,settings:{includePeriodicInBalance:true},accounts:[],categories:[],persons:[],incomes:[],expenses:[],transfers:[],scenarios:[]});
const isFiniteNumber=value=>typeof value==='number'&&Number.isFinite(value);
function normalizeMovement(item={}) { return { ...item, interval: validIntervals.has(item.interval)?item.interval:'monthly', dueMonth: item.interval && item.interval!=='monthly' && Number.isInteger(Number(item.dueMonth)) ? Number(item.dueMonth) : null, endDate: typeof item.endDate==='string' && item.endDate ? item.endDate : null }; }
function normalizeMovement(item={}) { const interval=validIntervals.has(item.interval)?item.interval:'monthly'; return { ...item, interval, dueMonth: interval!=='monthly'&&interval!=='once'&&Number.isInteger(Number(item.dueMonth)) ? Number(item.dueMonth) : null, oneTimeDate: interval==='once'&&typeof item.oneTimeDate==='string'&&item.oneTimeDate ? item.oneTimeDate : null, endDate: interval!=='once'&&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 normalizePersonIds(item={}) {
const raw=Array.isArray(item.personIds)?item.personIds:(typeof item.personId==='string'&&item.personId?[item.personId]:[]);
@@ -57,7 +57,7 @@ function normalizeData(input){
};
}
function isValidDateString(value){if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2}$/.test(value))return false;const d=new Date(`${value}T00:00:00Z`);return !Number.isNaN(d.getTime())&&d.toISOString().slice(0,10)===value;}
function validateMovement(item){if(!item.id||typeof item.id!=='string')throw new Error('Eintrag ohne gültige ID.');if(!item.name||typeof item.name!=='string')throw new Error('Eintrag ohne Namen.');if(!isFiniteNumber(item.amount)||item.amount<0)throw new Error(`Ungültiger Betrag bei '${item.name}'.`);if(!validIntervals.has(item.interval))throw new Error(`Ungültiges Intervall bei '${item.name}'.`);if(item.dueMonth!==null&&item.dueMonth!==undefined&&(!Number.isInteger(item.dueMonth)||item.dueMonth<1||item.dueMonth>12))throw new Error(`Ungültiger Monat bei '${item.name}'.`);if(item.endDate!==null&&item.endDate!==undefined&&!isValidDateString(item.endDate))throw new Error(`Ungültiges Enddatum bei '${item.name}'.`);}
function 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.interval==='once'&&(!item.oneTimeDate||!isValidDateString(item.oneTimeDate)))throw new Error(`Bitte ein gültiges Datum für die einmalige Position '${item.name}' setzen.`);if(item.dueMonth!==null&&item.dueMonth!==undefined&&(!Number.isInteger(item.dueMonth)||item.dueMonth<1||item.dueMonth>12))throw new Error(`Ungültiger Monat bei '${item.name}'.`);if(item.endDate!==null&&item.endDate!==undefined&&!isValidDateString(item.endDate))throw new Error(`Ungültiges Enddatum bei '${item.name}'.`);}
function validateAmountChanges(item){if(!Array.isArray(item.amountChanges))throw new Error(`Ungültige Betragsänderungen bei '${item.name}'.`);const dates=new Set();for(const change of item.amountChanges){if(!change.id||typeof change.id!=='string')throw new Error(`Betragsänderung ohne ID bei '${item.name}'.`);if(!isValidDateString(change.effectiveFrom))throw new Error(`Ungültiges Änderungsdatum bei '${item.name}'.`);if(!isFiniteNumber(change.amount)||change.amount<0)throw new Error(`Ungültiger Änderungsbetrag bei '${item.name}'.`);if(item.endDate&&change.effectiveFrom.slice(0,7)>item.endDate.slice(0,7))throw new Error(`Betragsänderung bei '${item.name}' liegt nach dem Enddatum.`);if(dates.has(change.effectiveFrom))throw new Error(`Für '${item.name}' existieren zwei Änderungen am selben Datum.`);dates.add(change.effectiveFrom);}}
function validatePersonAssignments(item,personIds,label='Eintrag'){
if(!Array.isArray(item.personIds))throw new Error(`Ungültige Personenzuordnung bei '${item.name||label}'.`);
@@ -87,7 +87,7 @@ function validateData(input){
if(a.ownership==='shared'&&a.ownerPersonIds.length<2)throw new Error(`Gemeinschaftskonto '${a.name}' braucht mindestens zwei Eigentümer.`);
if(a.ownership==='unassigned'&&a.ownerPersonIds.length)throw new Error(`Nicht zugeordnetes Konto '${a.name}' darf keine Eigentümer enthalten.`);
}
for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);validateAmountChanges(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie bei '${item.name}'.`);validatePersonAssignments(item,personIds);if(collection==='expenses'&&typeof item.pushoverReminder!=='boolean')throw new Error(`Ungültige Reminder-Einstellung bei '${item.name}'.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval!=='monthly'&&(item.dueMonth===null||item.dueMonth===undefined))throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Fälligkeitsmonat gesetzt sein.`);}
for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);validateAmountChanges(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie bei '${item.name}'.`);validatePersonAssignments(item,personIds);if(collection==='expenses'&&typeof item.pushoverReminder!=='boolean')throw new Error(`Ungültige Reminder-Einstellung bei '${item.name}'.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval!=='monthly'&&item.interval!=='once'&&(item.dueMonth===null||item.dueMonth===undefined))throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Fälligkeitsmonat gesetzt sein.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval==='once'&&!item.oneTimeDate)throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Datum gesetzt sein.`);}
for(const t of input.transfers){validateMovement(t);if(!accountIds.has(t.fromAccountId)||!accountIds.has(t.toAccountId))throw new Error(`Unbekanntes Konto beim Transfer '${t.name}'.`);if(t.fromAccountId===t.toAccountId)throw new Error('Quell- und Zielkonto müssen verschieden sein.');if(t.categoryId!==null&&t.categoryId!==undefined&&!categoryIds.has(t.categoryId))throw new Error(`Unbekannte Kategorie beim Transfer '${t.name}'.`);validatePersonAssignments(t,personIds,'Transfer');}
const scenarioIds=new Set();
for(const s of input.scenarios){
@@ -122,9 +122,10 @@ async function sendPushover(config,{title,message}){
return payload;
}
function monthKey(year,monthIndex){return `${year}-${String(monthIndex+1).padStart(2,'0')}`;}
function itemActiveInMonth(item,year,monthIndex){const end=typeof item.endDate==='string'?item.endDate.slice(0,7):'';return !end||monthKey(year,monthIndex)<=end;}
function itemActiveInMonth(item,year,monthIndex){if((item?.interval||'monthly')==='once'){return String(item.oneTimeDate||'').slice(0,7)===monthKey(year,monthIndex);}const end=typeof item.endDate==='string'?item.endDate.slice(0,7):'';return !end||monthKey(year,monthIndex)<=end;}
function amountInMonth(item,year,monthIndex){const key=monthKey(year,monthIndex);let amount=Number(item.amount||0);let best='';for(const change of item.amountChanges||[]){const mk=String(change.effectiveFrom||'').slice(0,7);if(mk&&mk<=key&&mk>=best){best=mk;amount=Number(change.amount||0);}}return amount;}
function occursInReminderMonth(item,year,monthIndex){
if((item?.interval||'monthly')==='once')return String(item.oneTimeDate||'').slice(0,7)===monthKey(year,monthIndex);
if(!itemActiveInMonth(item,year,monthIndex))return false;
const interval=item.interval||'monthly'; if(interval==='monthly')return true;
const dueMonth=Number(item.dueMonth); if(!Number.isInteger(dueMonth)||dueMonth<1||dueMonth>12)return false;
@@ -151,7 +152,7 @@ async function runPushoverReminderCheck(){
}
async function ensureDataFile(){await fs.mkdir(dataDir,{recursive:true});try{await fs.access(dataFile);}catch{await atomicWrite(emptyData(),false);}}
async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];const scenarioAdjustments=(parsed.scenarios||[]).flatMap(s=>s.adjustments||[]);if(parsed.version!==11||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.persons)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||movements.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||scenarioAdjustments.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||(parsed.incomes||[]).some(x=>!Array.isArray(x.amountChanges))||(parsed.expenses||[]).some(x=>!Array.isArray(x.amountChanges)||!Object.hasOwn(x,'pushoverReminder'))||movements.some(x=>!Object.hasOwn(x,'endDate'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')||!Object.hasOwn(a,'ownership')||!Array.isArray(a.ownerPersonIds)||!Object.hasOwn(a,'accountFunction')))await atomicWrite(data,true);return data;}
async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];const scenarioAdjustments=(parsed.scenarios||[]).flatMap(s=>s.adjustments||[]);if(parsed.version!==11||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.persons)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||movements.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||scenarioAdjustments.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||(parsed.incomes||[]).some(x=>!Array.isArray(x.amountChanges))||(parsed.expenses||[]).some(x=>!Array.isArray(x.amountChanges)||!Object.hasOwn(x,'pushoverReminder'))||movements.some(x=>!Object.hasOwn(x,'endDate'))||movements.some(x=>x.interval==='once'&&!Object.hasOwn(x,'oneTimeDate'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')||!Object.hasOwn(a,'ownership')||!Array.isArray(a.ownerPersonIds)||!Object.hasOwn(a,'accountFunction')))await atomicWrite(data,true);return data;}
async function atomicWrite(input,makeBackup=true){const data=normalizeData(input);validateData(data);await fs.mkdir(dataDir,{recursive:true});const tmp=`${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`;const payload=JSON.stringify(data,null,2)+'\n';if(makeBackup){try{await fs.copyFile(dataFile,backupFile);}catch(error){if(error.code!=='ENOENT')throw error;}}await fs.writeFile(tmp,payload,{encoding:'utf8',mode:0o600});await fs.rename(tmp,dataFile);}
app.disable('x-powered-by');app.use(express.json({limit:'1mb'}));app.get('/api/health',(_req,res)=>res.json({ok:true}));app.get('/api/data',async(_req,res,next)=>{try{res.json(await readData());}catch(e){next(e);}});app.put('/api/data',async(req,res,next)=>{try{await atomicWrite(req.body);res.json(await readData());}catch(e){next(e);}});app.get('/api/export',async(_req,res,next)=>{try{const data=await readData();res.setHeader('Content-Disposition','attachment; filename="fixfin-backup.json"');res.type('application/json').send(JSON.stringify(data,null,2)+'\n');}catch(e){next(e);}});app.get('/api/report.xlsx',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const personId=typeof req.query.person==='string'&&req.query.person?req.query.person:'all';const includeTransfers=req.query.transfers!=='0';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');if(personId!=='all'&&!data.persons.some(p=>p.id===personId))throw new Error('Bitte eine gültige Person für den Kontoauszug auswählen.');const buffer=await createExcelReport(data,{year,accountId,personId,includeTransfers});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');const personSuffix=personId==='all'?'':`-${data.persons.find(p=>p.id===personId)?.name||'Person'}`.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}${personSuffix}.xlsx"`);res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet').send(Buffer.from(buffer));}catch(e){next(e);}});app.get('/api/report.pdf',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const personId=typeof req.query.person==='string'&&req.query.person?req.query.person:'all';const includeTransfers=req.query.transfers!=='0';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');if(personId!=='all'&&!data.persons.some(p=>p.id===personId))throw new Error('Bitte eine gültige Person für den Kontoauszug auswählen.');const buffer=await createPdfReport(data,{year,accountId,personId,includeTransfers});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');const personSuffix=personId==='all'?'':`-${data.persons.find(p=>p.id===personId)?.name||'Person'}`.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}${personSuffix}.pdf"`);res.type('application/pdf').send(buffer);}catch(e){next(e);}});app.get('/api/pushover/config',async(_req,res,next)=>{try{const config=await readPushoverConfig();res.json(config?{configured:true,userMasked:maskKey(config.user)}:{configured:false,userMasked:''});}catch(e){next(e);}});app.put('/api/pushover/config',async(req,res,next)=>{try{res.json(await savePushoverConfig(req.body));}catch(e){next(e);}});app.delete('/api/pushover/config',async(_req,res,next)=>{try{await fs.rm(pushoverFile,{force:true});res.json({configured:false,userMasked:''});}catch(e){next(e);}});app.post('/api/pushover/test',async(_req,res,next)=>{try{const config=await readPushoverConfig();if(!config)throw new Error('Pushover ist noch nicht konfiguriert.');await sendPushover(config,{title:'FixFin · Test',message:'Pushover ist erfolgreich mit FixFin verbunden.'});res.json({ok:true});}catch(e){next(e);}});
const dist=path.resolve(__dirname,'../dist');app.use(express.static(dist));app.use((_req,res)=>res.sendFile(path.join(dist,'index.html')));app.use((error,_req,res,_next)=>{console.error(error);res.status(400).json({error:error.message||'Unbekannter Fehler'});});await ensureDataFile();app.listen(port,'0.0.0.0',()=>{console.log(`FixFin läuft auf Port ${port}`);console.log(`Datendatei: ${dataFile}`);console.log(`Pushover-Reminder: Prüfung am 1. des Monats ab ${String(reminderHour).padStart(2,'0')}:00 Uhr (${process.env.TZ||'System-Zeitzone'})`);});setTimeout(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),5000);setInterval(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),15*60*1000);
+4 -2
View File
@@ -2,13 +2,15 @@ import ExcelJS from 'exceljs';
import PDFDocument from 'pdfkit';
const MONTHS=['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
const INTERVALS={monthly:{label:'Monatlich',step:1},quarterly:{label:'Vierteljährlich',step:3},semiannual:{label:'Halbjährlich',step:6},yearly:{label:'Jährlich',step:12}};
const INTERVALS={monthly:{label:'Monatlich',step:1},quarterly:{label:'Vierteljährlich',step:3},semiannual:{label:'Halbjährlich',step:6},yearly:{label:'Jährlich',step:12},once:{label:'Einmalig',step:0}};
const money=new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'});
function monthKey(year,monthIndex){return `${year}-${String(monthIndex+1).padStart(2,'0')}`;}
function dateMonth(value){return typeof value==='string'&&/^\d{4}-\d{2}-\d{2}$/.test(value)?value.slice(0,7):null;}
function activeInMonth(item,year,monthIndex){const key=monthKey(year,monthIndex);const end=dateMonth(item.endDate);return !end||key<=end;}
function isOnce(item){return (item?.interval||'monthly')==='once';}
function activeInMonth(item,year,monthIndex){if(isOnce(item)){return dateMonth(item.oneTimeDate)===monthKey(year,monthIndex);}const key=monthKey(year,monthIndex);const end=dateMonth(item.endDate);return !end||key<=end;}
function occursInMonth(item,year,monthIndex){
if(isOnce(item))return dateMonth(item.oneTimeDate)===monthKey(year,monthIndex);
if(!activeInMonth(item,year,monthIndex))return false;
const interval=item.interval||'monthly'; if(interval==='monthly')return true;
const dueMonth=Number(item.dueMonth); if(!Number.isInteger(dueMonth)||dueMonth<1||dueMonth>12)return false;
+51 -15
View File
@@ -9,6 +9,7 @@ const intervals = {
quarterly: { label: 'Vierteljährlich', divisor: 3, step: 3 },
semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 },
yearly: { label: 'Jährlich', divisor: 12, step: 12 },
once: { label: 'Einmalig', divisor: 0, step: 0 },
};
const emptyData = { version: 11, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], persons: [], incomes: [], expenses: [], transfers: [], scenarios: [] };
const accountFunctions = {
@@ -57,21 +58,44 @@ function uid() {
}
function monthKey(year, monthIndex) { return `${year}-${String(monthIndex + 1).padStart(2, '0')}`; }
function dateMonth(value) { return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value.slice(0, 7) : null; }
function itemActiveInMonth(item, year, monthIndex) { const end = dateMonth(item.endDate); return !end || monthKey(year, monthIndex) <= end; }
function isOnce(item) { return (item?.interval || 'monthly') === 'once'; }
function itemActiveInMonth(item, year, monthIndex) {
if (isOnce(item)) {
const when = dateMonth(item.oneTimeDate);
if (!when) return false;
return monthKey(year, monthIndex) === when;
}
const end = dateMonth(item.endDate);
return !end || monthKey(year, monthIndex) <= end;
}
function effectiveAmount(item, year = new Date().getFullYear(), monthIndex = new Date().getMonth()) {
const key = monthKey(year, monthIndex); let amount = Number(item.amount || 0); let best = '';
for (const change of item.amountChanges || []) { const mk = dateMonth(change.effectiveFrom); if (mk && mk <= key && mk >= best) { best = mk; amount = Number(change.amount || 0); } }
return amount;
}
function monthlyEquivalent(item, year = new Date().getFullYear(), monthIndex = new Date().getMonth()) { return itemActiveInMonth(item, year, monthIndex) ? effectiveAmount(item, year, monthIndex) / (intervals[item.interval]?.divisor || 1) : 0; }
function annualEquivalent(item) { return monthlyEquivalent(item) * 12; }
function isPeriodic(item) { return (item.interval || 'monthly') !== 'monthly'; }
function monthlyEquivalent(item, year = new Date().getFullYear(), monthIndex = new Date().getMonth()) {
if (!itemActiveInMonth(item, year, monthIndex)) return 0;
if (isOnce(item)) return occursInMonth(item, monthIndex, year) ? effectiveAmount(item, year, monthIndex) : 0;
return effectiveAmount(item, year, monthIndex) / (intervals[item.interval]?.divisor || 1);
}
function annualEquivalent(item, year = new Date().getFullYear()) {
if (isOnce(item)) {
const when = dateMonth(item.oneTimeDate);
if (!when || !when.startsWith(`${year}-`)) return 0;
return effectiveAmount(item, year, Number(when.slice(5, 7)) - 1);
}
return monthlyEquivalent(item, year, new Date().getMonth()) * 12;
}
function isPeriodic(item) { return !['monthly', 'once'].includes(item.interval || 'monthly'); }
function balanceAmount(item, includePeriodic) {
const now = new Date(); if (!itemActiveInMonth(item, now.getFullYear(), now.getMonth())) return 0;
const now = new Date();
if (!itemActiveInMonth(item, now.getFullYear(), now.getMonth())) return 0;
if (isOnce(item)) return occursInMonth(item, now.getMonth(), now.getFullYear()) ? effectiveAmount(item, now.getFullYear(), now.getMonth()) : 0;
if (!isPeriodic(item)) return effectiveAmount(item, now.getFullYear(), now.getMonth());
return includePeriodic ? monthlyEquivalent(item, now.getFullYear(), now.getMonth()) : 0;
}
function occursInMonth(item, monthIndex, year = new Date().getFullYear()) {
if (isOnce(item)) return dateMonth(item.oneTimeDate) === monthKey(year, monthIndex);
if (!itemActiveInMonth(item, year, monthIndex)) return false;
const interval = item.interval || 'monthly';
if (interval === 'monthly') return true;
@@ -502,6 +526,15 @@ function reserveOverview(data) {
}
function formatDate(value){if(!value)return '';const d=new Date(`${value}T12:00:00`);return Number.isNaN(d.getTime())?value:new Intl.DateTimeFormat('de-DE',{dateStyle:'medium'}).format(d);}
function movementDueText(item){
if((item?.interval||'monthly')==='monthly')return 'Jeden Monat';
if((item?.interval||'monthly')==='once')return item.oneTimeDate?formatDate(item.oneTimeDate):'Bitte Datum setzen';
return item.dueMonth?months[Number(item.dueMonth)-1]:'Nicht festgelegt';
}
function movementAmountHint(item){
if((item?.interval||'monthly')==='once')return item.oneTimeDate?`einmalig am ${formatDate(item.oneTimeDate)}`:'einmalig';
return `${formatMoney(monthlyEquivalent(item))} / Monat`;
}
function buildWarnings(data, includeTransfers = true){
const now=new Date();const year=now.getFullYear();const currentMonth=now.getMonth();const horizon=new Date(now);horizon.setDate(horizon.getDate()+90);const warnings=[];
for(const account of data.accounts){const f=annualForecast(data,account.id,year,'all',includeTransfers);const negatives=f.rows.slice(currentMonth).filter(r=>r.balance<0);if(negatives.length){const worst=negatives.reduce((a,b)=>b.balance<a.balance?b:a);warnings.push({kind:'danger',title:`${account.name}: Unterdeckung im ${worst.name}`,text:`Geplante Abflüsse übersteigen die Zuflüsse um ${formatMoney(Math.abs(worst.balance))}.`,sort:0});}}
@@ -769,11 +802,11 @@ function Entries({type,items,accounts,categories,persons,onEdit,onDuplicate,onDe
const categoryNameLocal=id=>categories.find(c=>c.id===id)?.name||'Ohne Kategorie';
const personLabelLocal=item=>{const ids=personIdsOf(item);return ids.length?ids.map(id=>persons.find(p=>p.id===id)?.name||'Unbekannt').join(', '):'Ohne Person';};
const isIncome=type==='incomes';
const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt';
const dueText=movementDueText;
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>Nur Basisinfos bleiben sichtbar. Details und Aktionen öffnen sich je Bewegung per Akkordeon.</p></div></div>{items.length===0?<Empty text={`Noch keine ${isIncome?'Eingänge':'Ausgänge'} vorhanden.`}/>:<div className="movement-accordion-list">{items.map(x=><details className="movement-accordion" key={x.id}>
<summary><div className="movement-summary-main"><div className="movement-summary-copy"><strong>{x.name}</strong><div className="compact-badges"><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span><span className={`person-badge ${personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span><span className="account-badge">{accountNameLocal(x.accountId)}</span><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span>{x.interval!=='monthly'&&<span className="due-badge">{dueText(x)}</span>}{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder</span>}{(x.amountChanges||[]).length>0&&<span className="change-badge">{x.amountChanges.length} geplant</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div></div><div className="movement-summary-value"><strong className={isIncome?'good-text':'bad-text'}>{isIncome?'+':''}{formatMoney(currentAmount(x))}</strong><small>{formatMoney(monthlyEquivalent(x))} / Monat</small></div><span className="accordion-chevron" aria-hidden="true"></span></div></summary>
<div className="movement-accordion-body"><div className="movement-detail-grid"><div><span>Personen</span><strong>{personLabelLocal(x)}</strong></div><div><span>Konto</span><strong>{accountNameLocal(x.accountId)}</strong></div><div><span>Kategorie</span><strong>{categoryNameLocal(x.categoryId)}</strong></div><div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div><div><span>Fälligkeit</span><strong>{dueText(x)}</strong></div><div><span>Ø / Monat</span><strong>{formatMoney(monthlyEquivalent(x))}</strong></div>{x.endDate&&<div><span>Enddatum</span><strong>{formatDate(x.endDate)}</strong></div>}{!isIncome&&x.pushoverReminder&&<div><span>Erinnerung</span><strong>Am 1. des Fälligkeitsmonats</strong></div>}</div>{(x.amountChanges||[]).length>0&&<div className="movement-change-preview"><span>Geplante Betragsänderungen</span>{x.amountChanges.map(c=><div key={c.id||c.effectiveFrom}><strong>ab {formatDate(c.effectiveFrom)}</strong><span>{formatMoney(c.amount)}</span></div>)}</div>}<div className="movement-card-actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></div></div>
<summary><div className="movement-summary-main"><div className="movement-summary-copy"><strong>{x.name}</strong><div className="compact-badges"><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span><span className={`person-badge ${personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span><span className="account-badge">{accountNameLocal(x.accountId)}</span><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span>{x.interval!=='monthly'&&<span className="due-badge">{dueText(x)}</span>}{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder</span>}{(x.amountChanges||[]).length>0&&<span className="change-badge">{x.amountChanges.length} geplant</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div></div><div className="movement-summary-value"><strong className={isIncome?'good-text':'bad-text'}>{isIncome?'+':''}{formatMoney(currentAmount(x))}</strong><small>{movementAmountHint(x)}</small></div><span className="accordion-chevron" aria-hidden="true"></span></div></summary>
<div className="movement-accordion-body"><div className="movement-detail-grid"><div><span>Personen</span><strong>{personLabelLocal(x)}</strong></div><div><span>Konto</span><strong>{accountNameLocal(x.accountId)}</strong></div><div><span>Kategorie</span><strong>{categoryNameLocal(x.categoryId)}</strong></div><div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div><div><span>Fälligkeit</span><strong>{dueText(x)}</strong></div><div><span>{x.interval==='once'?'Einmalig':'Ø / Monat'}</span><strong>{x.interval==='once'?formatMoney(effectiveAmount(x)):formatMoney(monthlyEquivalent(x))}</strong></div>{x.endDate&&<div><span>Enddatum</span><strong>{formatDate(x.endDate)}</strong></div>}{!isIncome&&x.pushoverReminder&&<div><span>Erinnerung</span><strong>{x.interval==='once'?'Am 1. des Zielmonats':'Am 1. des Fälligkeitsmonats'}</strong></div>}</div>{(x.amountChanges||[]).length>0&&<div className="movement-change-preview"><span>Geplante Betragsänderungen</span>{x.amountChanges.map(c=><div key={c.id||c.effectiveFrom}><strong>ab {formatDate(c.effectiveFrom)}</strong><span>{formatMoney(c.amount)}</span></div>)}</div>}<div className="movement-card-actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></div></div>
</details>)}</div>}</section>;
}
@@ -781,10 +814,10 @@ function Transfers({data,onEdit,onDuplicate,onDelete}){
const accountNameLocal=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt';
const categoryNameLocal=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie';
const personLabelLocal=item=>transferPeopleLabel(data,item);
const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt';
const dueText=movementDueText;
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Quelle, Ziel, Betrag und Einordnung bleiben direkt sichtbar. Weitere Details und Aktionen liegen im Akkordeon.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<div className="movement-accordion-list">{data.transfers.map(x=>{const cls=transferClassification(data,x);return <details className="movement-accordion transfer-accordion" key={x.id}>
<summary><div className="movement-summary-main"><div className="movement-summary-copy"><strong>{x.name}</strong><span className="movement-summary-route">{accountNameLocal(x.fromAccountId)} <b></b> {accountNameLocal(x.toAccountId)}</span><div className="compact-badges"><span className={`transfer-class-badge ${cls.key}`}>{cls.label}</span><span className={`person-badge ${cls.key==='person-transfer'||personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span>{x.interval!=='monthly'&&<span className="due-badge">{dueText(x)}</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div></div><div className="movement-summary-value"><strong>{formatMoney(effectiveAmount(x))}</strong><small>{formatMoney(monthlyEquivalent(x))} / Monat</small></div><span className="accordion-chevron" aria-hidden="true"></span></div></summary>
<div className="movement-accordion-body"><div className="movement-detail-grid"><div><span>Von</span><strong>{accountNameLocal(x.fromAccountId)}</strong></div><div><span>Nach</span><strong>{accountNameLocal(x.toAccountId)}</strong></div><div><span>Personen</span><strong>{personLabelLocal(x)}</strong></div><div><span>Einordnung</span><strong>{cls.description}</strong></div><div><span>Kategorie</span><strong>{categoryNameLocal(x.categoryId)}</strong></div><div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div><div><span>Fälligkeit</span><strong>{dueText(x)}</strong></div><div><span>Ø / Monat</span><strong>{formatMoney(monthlyEquivalent(x))}</strong></div>{x.endDate&&<div><span>Enddatum</span><strong>{formatDate(x.endDate)}</strong></div>}</div><div className="movement-card-actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></div></div>
<summary><div className="movement-summary-main"><div className="movement-summary-copy"><strong>{x.name}</strong><span className="movement-summary-route">{accountNameLocal(x.fromAccountId)} <b></b> {accountNameLocal(x.toAccountId)}</span><div className="compact-badges"><span className={`transfer-class-badge ${cls.key}`}>{cls.label}</span><span className={`person-badge ${cls.key==='person-transfer'||personIdsOf(x).length?'':'muted-person'}`}>{personLabelLocal(x)}</span><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span>{x.interval!=='monthly'&&<span className="due-badge">{dueText(x)}</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div></div><div className="movement-summary-value"><strong>{formatMoney(effectiveAmount(x))}</strong><small>{movementAmountHint(x)}</small></div><span className="accordion-chevron" aria-hidden="true"></span></div></summary>
<div className="movement-accordion-body"><div className="movement-detail-grid"><div><span>Von</span><strong>{accountNameLocal(x.fromAccountId)}</strong></div><div><span>Nach</span><strong>{accountNameLocal(x.toAccountId)}</strong></div><div><span>Personen</span><strong>{personLabelLocal(x)}</strong></div><div><span>Einordnung</span><strong>{cls.description}</strong></div><div><span>Kategorie</span><strong>{categoryNameLocal(x.categoryId)}</strong></div><div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div><div><span>Fälligkeit</span><strong>{dueText(x)}</strong></div><div><span>{x.interval==='once'?'Einmalig':'Ø / Monat'}</span><strong>{x.interval==='once'?formatMoney(effectiveAmount(x)):formatMoney(monthlyEquivalent(x))}</strong></div>{x.endDate&&<div><span>Enddatum</span><strong>{formatDate(x.endDate)}</strong></div>}</div><div className="movement-card-actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></div></div>
</details>})}</div>}</section>;
}
@@ -1027,6 +1060,7 @@ function EditorDialog({dialog,data,onClose,onSave}){
[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):''),
[oneTimeDate,setOneTimeDate]=useState(item?.oneTimeDate||''),
[categoryId,setCategoryId]=useState(item?.categoryId||''),
[newCategoryName,setNewCategoryName]=useState(''),
[personIds,setPersonIds]=useState(personIdsOf(item)),
@@ -1054,14 +1088,16 @@ function EditorDialog({dialog,data,onClose,onSave}){
if(type==='accounts'&&accountOwnership==='shared'&&accountOwnerPersonIds.length<2)return setLocalError('Ein Gemeinschaftskonto braucht mindestens zwei Personen als Eigentümer.');
if(['incomes','expenses','transfers'].includes(type)&&personIds.length===0&&!newPersonName.trim())return setLocalError('Bitte mindestens eine Person auswählen oder direkt neu anlegen.');
if(type==='transfers'&&fromAccountId===toAccountId)return setLocalError('Quell- und Zielkonto müssen verschieden sein.');
if(type==='expenses'&&pushoverReminder&&interval!=='monthly'&&!dueMonth)return setLocalError('Für den Reminder bitte einen Fälligkeitsmonat festlegen.');
if(interval==='once'&&!oneTimeDate)return setLocalError('Bitte ein Datum für die einmalige Position festlegen.');
if(type==='expenses'&&pushoverReminder&&interval!=='monthly'&&interval!=='once'&&!dueMonth)return setLocalError('Für den Reminder bitte einen Fälligkeitsmonat festlegen.');
if(type==='expenses'&&pushoverReminder&&interval==='once'&&!oneTimeDate)return setLocalError('Für den Reminder bitte das Zieldatum festlegen.');
if(endDate&&amountChanges.some(c=>c.effectiveFrom.slice(0,7)>endDate.slice(0,7)))return setLocalError('Eine geplante Betragsänderung liegt nach dem Enddatum.');
let next=structuredClone(data); const id=editing?item.id:uid(); let value;
if(type==='accounts') value={id,name:name.trim(),ownership:accountOwnership,ownerPersonIds:[...new Set(accountOwnerPersonIds)],accountFunction};
else if(type==='persons') { const duplicate=(next.persons||[]).some(p=>p.id!==item?.id&&p.name.trim().toLocaleLowerCase('de-DE')===name.trim().toLocaleLowerCase('de-DE')); if(duplicate)return setLocalError('Diese Person existiert bereits.'); value={id,name:name.trim()}; }
else if(type==='categories') { const duplicate=next.categories.some(c=>c.id!==item?.id&&c.name.trim().toLocaleLowerCase('de-DE')===name.trim().toLocaleLowerCase('de-DE')); if(duplicate)return setLocalError('Diese Kategorie existiert bereits.'); value={id,name:name.trim()}; }
else {
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:['monthly','once'].includes(interval)?null:(dueMonth?Number(dueMonth):null),oneTimeDate:interval==='once'?(oneTimeDate||null):null,endDate:interval==='once'?null:(endDate||null)};
let selectedCategoryId=categoryId||null;
if(categoryId==='__new__'){const categoryNameValue=newCategoryName.trim();if(!categoryNameValue)return setLocalError('Bitte einen Namen für die neue Kategorie eingeben.');const existing=next.categories.find(c=>c.name.trim().toLocaleLowerCase('de-DE')===categoryNameValue.toLocaleLowerCase('de-DE'));if(existing) selectedCategoryId=existing.id;else { selectedCategoryId=uid(); next.categories.push({id:selectedCategoryId,name:categoryNameValue}); }}
let selectedPersonIds=[...new Set(personIds)];
@@ -1084,14 +1120,14 @@ function EditorDialog({dialog,data,onClose,onSave}){
const simpleType=type==='accounts'||type==='categories'||type==='persons';
return <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}><div className="modal-head"><div><span className="eyebrow">{dialog.duplicate?'DUPLIZIEREN':editing?'BEARBEITEN':'NEU'}</span><h2>{labels[type]}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>{localError&&<div className="alert">{localError}</div>}<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder={type==='accounts'?'z. B. Girokonto':type==='categories'?'z. B. Wohnen':type==='persons'?'z. B. Michael':'z. B. Kfz-Versicherung'}/></label>
{type==='accounts'&&<section className="account-editor-fields"><label><span>Eigentum</span><select value={accountOwnership} onChange={e=>{const value=e.target.value;setAccountOwnership(value);if(value==='personal'&&accountOwnerPersonIds.length>1)setAccountOwnerPersonIds(accountOwnerPersonIds.slice(0,1));}}>{accountOwnership==='unassigned'&&<option value="unassigned" disabled>Bitte zuordnen</option>}<option value="personal">Persönlich</option><option value="shared">Gemeinsam</option></select><small className="field-help">Eigentum entscheidet, ob Eingänge persönlich oder gemeinsam sind und wie Transfers eingeordnet werden.</small></label>{accountOwnership==='personal'?<label><span>Eigentümer</span><select value={accountOwnerPersonIds[0]||''} onChange={e=>setAccountOwnerPersonIds(e.target.value?[e.target.value]:[])}><option value="">Bitte auswählen</option>{(data.persons||[]).map(p=><option key={p.id} value={p.id}>{p.name}</option>)}</select></label>:<PersonMultiSelect persons={data.persons||[]} selectedIds={accountOwnerPersonIds} onChange={setAccountOwnerPersonIds} label="Gemeinsame Eigentümer" help="Mindestens zwei Personen. Diese Zuordnung beschreibt, wem der gemeinsame Geldtopf gehört."/>}<label><span>Kontofunktion</span><select value={accountFunction} onChange={e=>setAccountFunction(e.target.value)}>{Object.entries(accountFunctions).map(([key,value])=><option key={key} value={key}>{value.label}</option>)}</select><small className="field-help">Damit erkennt FixFin z. B. Rücklagen, Depot- und Bauspar-Umbuchungen automatisch.</small></label></section>}
{!simpleType&&<><label><span>Betrag pro Intervall</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span></span></div></label><label><span>Intervall</span><select value={interval} onChange={e=>setInterval(e.target.value)}>{Object.entries(intervals).map(([key,x])=><option key={key} value={key}>{x.label}</option>)}</select></label>{interval!=='monthly'&&<label><span>{interval==='yearly'?'Monat der Fälligkeit (optional)':'Erster Fälligkeitsmonat (optional)'}</span><select value={dueMonth} onChange={e=>setDueMonth(e.target.value)}><option value="">Nicht festgelegt</option>{months.map((m,i)=><option key={m} value={i+1}>{m}</option>)}</select><small className="field-help">{interval==='quarterly'?'Ab diesem Monat alle 3 Monate.':interval==='semiannual'?'Ab diesem Monat alle 6 Monate.':'Für die Jahresprognose.'}</small></label>}<label><span>Enddatum / Laufzeit (optional)</span><input type="date" value={endDate} onChange={e=>setEndDate(e.target.value)}/><small className="field-help">Der Monat des Enddatums wird noch vollständig eingeplant.</small></label></>}
{!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==='once'&&<label><span>Einmaliges Datum</span><input type="date" value={oneTimeDate} onChange={e=>setOneTimeDate(e.target.value)}/><small className="field-help">Die Position erscheint nur im Monat dieses Datums in Jahresplanung, Statistiken und Kontoauszügen.</small></label>}{interval!=='monthly'&&interval!=='once'&&<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>}{interval!=='once'&&<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')&&<>
<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')&&<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')&&interval!=='once'&&<section className="amount-change-panel"><div className="amount-change-head"><div><strong>Geplante Betragsänderungen</strong><span>Ein neuer Betrag gilt ab dem Monat des gewählten Datums. Der Basisbetrag oben bleibt für frühere Monate erhalten.</span></div><span className="change-count">{amountChanges.length}</span></div>{amountChanges.length>0&&<div className="amount-change-list">{amountChanges.map(c=><div className="amount-change-row" key={c.id}><div><span>ab {formatDate(c.effectiveFrom)}</span><strong>{formatMoney(c.amount)}</strong></div><button type="button" className="danger" onClick={()=>setAmountChanges(amountChanges.filter(x=>x.id!==c.id))}>Entfernen</button></div>)}</div>}<div className="amount-change-add"><label><span>Wirksam ab</span><input type="date" value={changeDate} onChange={e=>setChangeDate(e.target.value)}/></label><label><span>Neuer Betrag</span><div className="money-input"><input inputMode="decimal" value={changeAmount} onChange={e=>setChangeAmount(e.target.value)} placeholder="0,00"/><span></span></div></label><button type="button" className="secondary" onClick={addAmountChange}>+ Änderung</button></div></section>}
{type==='expenses'&&<button type="button" className={`reminder-toggle ${pushoverReminder?'active':''}`} aria-pressed={pushoverReminder} onClick={()=>setPushoverReminder(x=>!x)}><span className={`checkbox-mark ${pushoverReminder?'checked':''}`}>{pushoverReminder?'✓':''}</span><span><strong>Pushover-Erinnerung</strong><small>Am 1. des Fälligkeitsmonats mit Betrag und Konto erinnern.</small></span></button>}
{type==='transfers'&&<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button type="submit" className="primary">Speichern</button></div></form></div>;
}