V3.36
This commit is contained in:
@@ -169,3 +169,8 @@ Die Statistik erklärt jetzt sämtliche fachlichen Kennzahlen und Zwischenwerte
|
|||||||
### v3.35 – geplante Betragsänderungen für Transfers
|
### v3.35 – geplante Betragsänderungen für Transfers
|
||||||
|
|
||||||
Transfers unterstützen jetzt dieselben zukünftigen Betragsänderungen wie Eingänge und Ausgänge. Der Basisbetrag gilt bis zur ersten Änderung; jede Änderung wird ab ihrem Wirksamkeitsdatum verwendet. Die geänderten Transferbeträge fließen korrekt in Monats-/Jahresplanung, Kontoliquidität, Statistik, Transferanalyse, Gemeinschaftsfinanzierung und PDF/XLSX-Kontoauszüge ein. In der Bewegungsansicht werden geplante Änderungen als Badge und im Detail-Akkordeon angezeigt. Bestehende Transfers werden automatisch mit einem leeren `amountChanges`-Array normalisiert; das JSON-Datenmodell bleibt bei `version: 11`.
|
Transfers unterstützen jetzt dieselben zukünftigen Betragsänderungen wie Eingänge und Ausgänge. Der Basisbetrag gilt bis zur ersten Änderung; jede Änderung wird ab ihrem Wirksamkeitsdatum verwendet. Die geänderten Transferbeträge fließen korrekt in Monats-/Jahresplanung, Kontoliquidität, Statistik, Transferanalyse, Gemeinschaftsfinanzierung und PDF/XLSX-Kontoauszüge ein. In der Bewegungsansicht werden geplante Änderungen als Badge und im Detail-Akkordeon angezeigt. Bestehende Transfers werden automatisch mit einem leeren `amountChanges`-Array normalisiert; das JSON-Datenmodell bleibt bei `version: 11`.
|
||||||
|
|
||||||
|
|
||||||
|
### v3.36 – Betragsänderungen: aktuell, Historie und Planung
|
||||||
|
|
||||||
|
Betragsänderungen werden nach ihrem Stichtag nicht mehr als geplant dargestellt. Der aktuell wirksame Betrag wird in Bewegungen als normaler Betrag angezeigt. Frühere Beträge und bereits wirksame Änderungen bleiben als Historie sichtbar und werden für vergangene Monate/Jahre weiterhin verwendet. Nur zukünftige Stichtage erhalten das Badge `geplant`. Das gilt einheitlich für Eingänge, Ausgänge und Transfers.
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const SHELL_CACHE='fixfin-shell-v3.35';
|
const SHELL_CACHE='fixfin-shell-v3.36';
|
||||||
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'];
|
||||||
|
|
||||||
|
|||||||
+46
-13
@@ -718,12 +718,44 @@ function movementAmountHint(item){
|
|||||||
if((item?.interval||'monthly')==='once')return item.oneTimeDate?`einmalig am ${formatDate(item.oneTimeDate)}`:'einmalig';
|
if((item?.interval||'monthly')==='once')return item.oneTimeDate?`einmalig am ${formatDate(item.oneTimeDate)}`:'einmalig';
|
||||||
return `${formatMoney(monthlyEquivalent(item))} / Monat`;
|
return `${formatMoney(monthlyEquivalent(item))} / Monat`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function previousMonthEndDate(value){
|
||||||
|
const mk=dateMonth(value);if(!mk)return null;
|
||||||
|
const [year,month]=mk.split('-').map(Number);
|
||||||
|
const d=new Date(year,month-1,0,12,0,0);
|
||||||
|
const y=d.getFullYear(),m=String(d.getMonth()+1).padStart(2,'0'),day=String(d.getDate()).padStart(2,'0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
function amountChangeState(item,asOf=new Date()){
|
||||||
|
const currentKey=monthKey(asOf.getFullYear(),asOf.getMonth());
|
||||||
|
const changes=[...(item?.amountChanges||[])].filter(c=>dateMonth(c.effectiveFrom)).sort((a,b)=>a.effectiveFrom.localeCompare(b.effectiveFrom));
|
||||||
|
const applied=changes.filter(c=>dateMonth(c.effectiveFrom)<=currentKey);
|
||||||
|
const future=changes.filter(c=>dateMonth(c.effectiveFrom)>currentKey);
|
||||||
|
const activeChange=applied.length?applied[applied.length-1]:null;
|
||||||
|
const history=[];
|
||||||
|
let previousAmount=Number(item?.amount||0);let previousFrom=null;
|
||||||
|
for(const change of applied){
|
||||||
|
history.push({amount:previousAmount,from:previousFrom,until:previousMonthEndDate(change.effectiveFrom)});
|
||||||
|
previousAmount=Number(change.amount||0);previousFrom=change.effectiveFrom;
|
||||||
|
}
|
||||||
|
return {changes,applied,future,activeChange,currentAmount:activeChange?Number(activeChange.amount||0):Number(item?.amount||0),currentSince:activeChange?.effectiveFrom||null,history};
|
||||||
|
}
|
||||||
|
function AmountChangeTimeline({item}){
|
||||||
|
const state=amountChangeState(item);
|
||||||
|
if(!state.changes.length)return null;
|
||||||
|
return <div className="amount-timeline-stack">
|
||||||
|
{state.history.length>0&&<div className="movement-change-preview history"><span>Betragshistorie</span>{state.history.map((row,index)=><div key={`history-${index}-${row.until||''}`}><strong>{row.from?`${formatDate(row.from)} bis ${formatDate(row.until)}`:`bis ${formatDate(row.until)}`}</strong><span>{formatMoney(row.amount)}</span></div>)}</div>}
|
||||||
|
{state.currentSince&&<div className="movement-change-preview current"><span>Aktuell gültig</span><div><strong>seit {formatDate(state.currentSince)}</strong><span>{formatMoney(state.currentAmount)}</span></div></div>}
|
||||||
|
{state.future.length>0&&<div className="movement-change-preview"><span>Geplante Betragsänderungen</span>{state.future.map(c=><div key={c.id||c.effectiveFrom}><strong>ab {formatDate(c.effectiveFrom)}</strong><span>{formatMoney(c.amount)}</span></div>)}</div>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
function buildWarnings(data, includeTransfers = true){
|
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=[];
|
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});}}
|
for(const account of data.accounts){const f=annualForecast(data,account.id,year,'all',includeTransfers);const negatives=f.rows.slice(currentMonth).filter(r=>r.balance<0);if(negatives.length){const worst=negatives.reduce((a,b)=>b.balance<a.balance?b:a);warnings.push({kind:'danger',title:`${account.name}: Unterdeckung im ${worst.name}`,text:`Geplante Abflüsse übersteigen die Zuflüsse um ${formatMoney(Math.abs(worst.balance))}.`,sort:0});}}
|
||||||
for(let m=currentMonth;m<12;m++){const due=(data.expenses||[]).filter(x=>isPeriodic(x)&&occursInMonth(x,m,year));if(due.length>=3){const total=due.reduce((sum,x)=>sum+effectiveAmount(x,year,m),0);warnings.push({kind:'warning',title:`${due.length} periodische Ausgaben im ${months[m]}`,text:`Zusammen ${formatMoney(total)}. Dieser Monat bündelt mehrere größere Fälligkeiten.`,sort:1});}}
|
for(let m=currentMonth;m<12;m++){const due=(data.expenses||[]).filter(x=>isPeriodic(x)&&occursInMonth(x,m,year));if(due.length>=3){const total=due.reduce((sum,x)=>sum+effectiveAmount(x,year,m),0);warnings.push({kind:'warning',title:`${due.length} periodische Ausgaben im ${months[m]}`,text:`Zusammen ${formatMoney(total)}. Dieser Monat bündelt mehrere größere Fälligkeiten.`,sort:1});}}
|
||||||
for(const [type,items] of [['Eingang',data.incomes||[]],['Ausgang',data.expenses||[]]])for(const item of items){for(const change of item.amountChanges||[]){const d=new Date(`${change.effectiveFrom}T12:00:00`);if(d>=now&&d<=horizon)warnings.push({kind:'info',title:`${item.name}: Betrag ändert sich`,text:`${type} auf ${accountName(data,item.accountId)} ab ${formatDate(change.effectiveFrom)}: ${formatMoney(change.amount)}.`,sort:2,date:d});}if(item.endDate){const d=new Date(`${item.endDate}T12:00:00`);if(d>=now&&d<=horizon)warnings.push({kind:'info',title:`${item.name} läuft aus`,text:`${type} auf ${accountName(data,item.accountId)} endet am ${formatDate(item.endDate)}.`,sort:3,date:d});}}
|
const currentKey=monthKey(year,currentMonth);const horizonKey=monthKey(horizon.getFullYear(),horizon.getMonth());
|
||||||
for(const item of data.transfers||[]){for(const change of item.amountChanges||[]){const d=new Date(`${change.effectiveFrom}T12:00:00`);if(d>=now&&d<=horizon)warnings.push({kind:'info',title:`${item.name}: Transferbetrag ändert sich`,text:`${accountName(data,item.fromAccountId)} → ${accountName(data,item.toAccountId)} ab ${formatDate(change.effectiveFrom)}: ${formatMoney(change.amount)}.`,sort:2,date:d});}}
|
for(const [type,items] of [['Eingang',data.incomes||[]],['Ausgang',data.expenses||[]]])for(const item of items){for(const change of item.amountChanges||[]){const mk=dateMonth(change.effectiveFrom);const d=new Date(`${change.effectiveFrom}T12:00:00`);if(mk&&mk>currentKey&&mk<=horizonKey)warnings.push({kind:'info',title:`${item.name}: Betrag ändert sich`,text:`${type} auf ${accountName(data,item.accountId)} ab ${formatDate(change.effectiveFrom)}: ${formatMoney(change.amount)}.`,sort:2,date:d});}if(item.endDate){const d=new Date(`${item.endDate}T12:00:00`);if(d>=now&&d<=horizon)warnings.push({kind:'info',title:`${item.name} läuft aus`,text:`${type} auf ${accountName(data,item.accountId)} endet am ${formatDate(item.endDate)}.`,sort:3,date:d});}}
|
||||||
|
for(const item of data.transfers||[]){for(const change of item.amountChanges||[]){const mk=dateMonth(change.effectiveFrom);const d=new Date(`${change.effectiveFrom}T12:00:00`);if(mk&&mk>currentKey&&mk<=horizonKey)warnings.push({kind:'info',title:`${item.name}: Transferbetrag ändert sich`,text:`${accountName(data,item.fromAccountId)} → ${accountName(data,item.toAccountId)} ab ${formatDate(change.effectiveFrom)}: ${formatMoney(change.amount)}.`,sort:2,date:d});}}
|
||||||
return warnings.sort((a,b)=>a.sort-b.sort||(a.date?.getTime()||0)-(b.date?.getTime()||0)).slice(0,10);
|
return warnings.sort((a,b)=>a.sort-b.sort||(a.date?.getTime()||0)-(b.date?.getTime()||0)).slice(0,10);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,11 +1022,10 @@ function Entries({type,items,accounts,categories,persons,onEdit,onDuplicate,onDe
|
|||||||
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 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=movementDueText;
|
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>Der sichtbare Betrag ist immer der aktuell gültige Betrag. Vergangene Änderungen bleiben als Historie erhalten; nur zukünftige Stichtage werden als geplant markiert.</p></div></div>{items.length===0?<Empty text={`Noch keine ${isIncome?'Eingänge':'Ausgänge'} vorhanden.`}/>:<div className="movement-accordion-list">{items.map(x=>{const changes=amountChangeState(x);return <details className="movement-accordion" key={x.id}>
|
||||||
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>}{changes.future.length>0&&<span className="change-badge">{changes.future.length} geplant</span>}{changes.applied.length>0&&<span className="history-badge">Historie</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(changes.currentAmount)}</strong><small>{x.interval==='once'?movementAmountHint(x):`${formatMoney(changes.currentAmount/(intervals[x.interval]?.divisor||1))} / Monat`}</small></div><span className="accordion-chevron" aria-hidden="true">⌄</span></div></summary>
|
||||||
<summary><div className="movement-summary-main"><div className="movement-summary-copy"><strong>{x.name}</strong><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':'Aktuell Ø / Monat'}</span><strong>{x.interval==='once'?formatMoney(changes.currentAmount):formatMoney(changes.currentAmount/(intervals[x.interval]?.divisor||1))}</strong></div>{changes.currentSince&&<div><span>Aktueller Betrag seit</span><strong>{formatDate(changes.currentSince)}</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><AmountChangeTimeline item={x}/><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>
|
||||||
<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>;
|
||||||
</details>)}</div>}</section>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Transfers({data,onEdit,onDuplicate,onDelete}){
|
function Transfers({data,onEdit,onDuplicate,onDelete}){
|
||||||
@@ -1002,9 +1033,9 @@ function Transfers({data,onEdit,onDuplicate,onDelete}){
|
|||||||
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 personLabelLocal=item=>transferPeopleLabel(data,item);
|
const personLabelLocal=item=>transferPeopleLabel(data,item);
|
||||||
const dueText=movementDueText;
|
const dueText=movementDueText;
|
||||||
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Quelle, Ziel, Betrag und Einordnung bleiben direkt sichtbar. Weitere Details und Aktionen liegen im Akkordeon.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<div className="movement-accordion-list">{data.transfers.map(x=>{const cls=transferClassification(data,x);return <details className="movement-accordion transfer-accordion" key={x.id}>
|
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Der sichtbare Betrag ist immer der aktuell gültige Transferbetrag. Vergangene Stichtage wandern automatisch in die Historie, zukünftige bleiben als Planung sichtbar.</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);const changes=amountChangeState(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.amountChanges||[]).length>0&&<span className="change-badge">{x.amountChanges.length} geplant</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div></div><div className="movement-summary-value"><strong>{formatMoney(effectiveAmount(x))}</strong><small>{movementAmountHint(x)}</small></div><span className="accordion-chevron" aria-hidden="true">⌄</span></div></summary>
|
<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>}{changes.future.length>0&&<span className="change-badge">{changes.future.length} geplant</span>}{changes.applied.length>0&&<span className="history-badge">Historie</span>}{x.endDate&&<span className="end-badge">bis {formatDate(x.endDate)}</span>}</div></div><div className="movement-summary-value"><strong>{formatMoney(changes.currentAmount)}</strong><small>{x.interval==='once'?movementAmountHint(x):`${formatMoney(changes.currentAmount/(intervals[x.interval]?.divisor||1))} / 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>{x.interval==='once'?'Einmalig':'Ø / Monat'}</span><strong>{x.interval==='once'?formatMoney(effectiveAmount(x)):formatMoney(monthlyEquivalent(x))}</strong></div>{x.endDate&&<div><span>Enddatum</span><strong>{formatDate(x.endDate)}</strong></div>}</div>{(x.amountChanges||[]).length>0&&<div className="movement-change-preview"><span>Geplante Betragsänderungen</span>{x.amountChanges.map(c=><div key={c.id||c.effectiveFrom}><strong>ab {formatDate(c.effectiveFrom)}</strong><span>{formatMoney(c.amount)}</span></div>)}</div>}<div className="movement-card-actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></div></div>
|
<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':'Aktuell Ø / Monat'}</span><strong>{x.interval==='once'?formatMoney(changes.currentAmount):formatMoney(changes.currentAmount/(intervals[x.interval]?.divisor||1))}</strong></div>{changes.currentSince&&<div><span>Aktueller Betrag seit</span><strong>{formatDate(changes.currentSince)}</strong></div>}{x.endDate&&<div><span>Enddatum</span><strong>{formatDate(x.endDate)}</strong></div>}</div><AmountChangeTimeline item={x}/><div className="movement-card-actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></div></div>
|
||||||
</details>})}</div>}</section>;
|
</details>})}</div>}</section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1252,7 +1283,7 @@ function TransferAnalysisPage({data}){
|
|||||||
<section className="summary-grid transfer-analysis-summary"><CountMetric label="Gefundene Transfers" value={stats.rows.length}/><Metric label={`Transfervolumen ${year}`} value={stats.annual}/><Metric label="Ø Transfervolumen / Monat" value={stats.monthly}/><Metric label={`Echte Einnahmen Quellen ${year}`} value={stats.sourceIncomeAnnual}/><StatMetricCard label="Transferquote zu Einnahmen" displayValue={percentValue(stats.incomeRatio)} featured helpTitle="Transferquote zu Einnahmen" help={<><p>Diese Quote beantwortet: <strong>Wie viel des echten Einkommens der ausgewählten Quellkonten wird über die aktuell ausgewählten Transfers weiterverteilt?</strong></p><p>Formel: ausgewähltes Transfervolumen ÷ echte Einnahmen der ausgewählten Quellkonten × 100.</p><p>Transfers, die auf einem Quellkonto eingehen, zählen nicht als Einnahmen. Die Quote kann über 100 % liegen, wenn zusätzlich vorhandenes Guthaben oder eingehende Transfers weiterverteilt werden.</p></>}><StatFormulaRows rows={[{label:'Ausgewählte Transfers',value:stats.annual},{label:'Echte Einnahmen der Quellkonten',value:stats.sourceIncomeAnnual},{label:'Transferquote',value:percentValue(stats.incomeRatio)}]}/></StatMetricCard><CountMetric label="Quellen mit Treffern" value={stats.sources.length}/></section>
|
<section className="summary-grid transfer-analysis-summary"><CountMetric label="Gefundene Transfers" value={stats.rows.length}/><Metric label={`Transfervolumen ${year}`} value={stats.annual}/><Metric label="Ø Transfervolumen / Monat" value={stats.monthly}/><Metric label={`Echte Einnahmen Quellen ${year}`} value={stats.sourceIncomeAnnual}/><StatMetricCard label="Transferquote zu Einnahmen" displayValue={percentValue(stats.incomeRatio)} featured helpTitle="Transferquote zu Einnahmen" help={<><p>Diese Quote beantwortet: <strong>Wie viel des echten Einkommens der ausgewählten Quellkonten wird über die aktuell ausgewählten Transfers weiterverteilt?</strong></p><p>Formel: ausgewähltes Transfervolumen ÷ echte Einnahmen der ausgewählten Quellkonten × 100.</p><p>Transfers, die auf einem Quellkonto eingehen, zählen nicht als Einnahmen. Die Quote kann über 100 % liegen, wenn zusätzlich vorhandenes Guthaben oder eingehende Transfers weiterverteilt werden.</p></>}><StatFormulaRows rows={[{label:'Ausgewählte Transfers',value:stats.annual},{label:'Echte Einnahmen der Quellkonten',value:stats.sourceIncomeAnnual},{label:'Transferquote',value:percentValue(stats.incomeRatio)}]}/></StatMetricCard><CountMetric label="Quellen mit Treffern" value={stats.sources.length}/></section>
|
||||||
|
|
||||||
{sourceIds.length===0||targetIds.length===0?<section className="panel"><Empty text="Bitte mindestens ein Quell- und ein Zielkonto auswählen."/></section>:stats.rows.length===0?<section className="panel"><Empty text={`Für die gewählte Kombination gibt es ${year} keine geplanten Transfers.`}/></section>:<>
|
{sourceIds.length===0||targetIds.length===0?<section className="panel"><Empty text="Bitte mindestens ein Quell- und ein Zielkonto auswählen."/></section>:stats.rows.length===0?<section className="panel"><Empty text={`Für die gewählte Kombination gibt es ${year} keine geplanten Transfers.`}/></section>:<>
|
||||||
<section className="panel transfer-source-overview"><div className="section-head"><div><h2>Wie viel kommt von welchem Konto?</h2><p>Jede Quelle zeigt ihr gesamtes Transfervolumen, die Verteilung auf die ausgewählten Zielkonten und den Anteil dieses Volumens an den echten Einnahmen des Quellkontos.</p></div><StatsInfoButton title="Transferquote je Quellkonto"><p><strong>Transferquote = ausgewählte Transfers dieses Quellkontos ÷ echte Einnahmen dieses Quellkontos × 100.</strong></p><p>Nur echte Eingänge zählen als Einnahmen. Eingehende Transfers sind kein Einkommen. Deshalb kann die Quote auch über 100 % liegen.</p></StatsInfoButton></div><div className="transfer-source-list">{stats.sources.map(source=><details className="transfer-source-card" key={source.accountId} open={stats.sources.length===1}><summary><div><strong>{accountName(data,source.accountId)}</strong><span>{source.items.length} {source.items.length===1?'Transfer':'Transfers'} · {percent(source.annual,stats.annual)} des ausgewählten Volumens · <b>{percentValue(source.incomeRatio)} der Einnahmen</b></span></div><div className="transfer-source-total"><strong>{formatMoney(source.annual)} / Jahr</strong><small>{formatMoney(source.monthly)} Ø / Monat</small></div><span className="accordion-chevron" aria-hidden="true">⌄</span></summary><div className="transfer-source-body"><div className="transfer-income-ratio-strip"><div><span>Echte Einnahmen</span><strong>{formatMoney(source.incomeAnnual)} / Jahr</strong></div><div><span>Ausgewählte Transfers</span><strong>{formatMoney(source.annual)} / Jahr</strong></div><div><span>Transferquote</span><strong>{percentValue(source.incomeRatio)}</strong></div></div><div className="transfer-target-distribution">{source.targets.map(target=><div className="transfer-target-row" key={target.accountId}><div><strong>{accountName(data,target.accountId)}</strong><span>{target.items.length} {target.items.length===1?'Position':'Positionen'}</span></div><div className="transfer-target-bar"><span style={{width:`${Math.max(target.annual/source.annual*100,1)}%`}}/></div><div><strong>{formatMoney(target.annual)}</strong><span>{percent(target.annual,source.annual)}</span></div></div>)}</div><div className="transfer-analysis-detail-list">{source.items.sort((a,b)=>b.annual-a.annual).map(item=><details className="transfer-analysis-detail" key={item.id}><summary><div><strong>{item.name}</strong><span>{accountName(data,item.fromAccountId)} → {accountName(data,item.toAccountId)}</span></div><div><strong>{formatMoney(item.annual)} / Jahr</strong><small>{percentValue(item.incomeRatio)} der Einnahmen der Quelle</small></div><span className="accordion-chevron" aria-hidden="true">⌄</span></summary><div className="transfer-analysis-detail-body"><span className={`transfer-class-badge ${item.classification.key}`}>{item.classification.label}</span><div><span>{(item.amountChanges||[]).length?'Basisbetrag je Intervall':'Betrag je Intervall'}</span><strong>{formatMoney(item.amount)}</strong></div><div><span>Intervall</span><strong>{intervalLabel(item)}</strong></div><div><span>Fälligkeit</span><strong>{movementDueText(item)}</strong></div><div><span>Personen</span><strong>{transferPeopleLabel(data,item)}</strong></div><div><span>Kategorie</span><strong>{categoryName(data,item.categoryId)}</strong></div><div><span>Anteil dieser Quelle</span><strong>{percent(item.annual,source.annual)}</strong></div><div><span>Echte Einnahmen Quelle</span><strong>{formatMoney(item.sourceIncomeAnnual)} / Jahr</strong></div><div><span>Transfer / Einnahmen Quelle</span><strong>{percentValue(item.incomeRatio)}</strong></div>{(item.amountChanges||[]).length>0&&<div className="transfer-analysis-changes"><span>Geplante Betragsänderungen</span><strong>{item.amountChanges.map(change=>`ab ${formatDate(change.effectiveFrom)}: ${formatMoney(change.amount)}`).join(' · ')}</strong></div>}</div></details>)}</div></div></details>)}</div></section>
|
<section className="panel transfer-source-overview"><div className="section-head"><div><h2>Wie viel kommt von welchem Konto?</h2><p>Jede Quelle zeigt ihr gesamtes Transfervolumen, die Verteilung auf die ausgewählten Zielkonten und den Anteil dieses Volumens an den echten Einnahmen des Quellkontos.</p></div><StatsInfoButton title="Transferquote je Quellkonto"><p><strong>Transferquote = ausgewählte Transfers dieses Quellkontos ÷ echte Einnahmen dieses Quellkontos × 100.</strong></p><p>Nur echte Eingänge zählen als Einnahmen. Eingehende Transfers sind kein Einkommen. Deshalb kann die Quote auch über 100 % liegen.</p></StatsInfoButton></div><div className="transfer-source-list">{stats.sources.map(source=><details className="transfer-source-card" key={source.accountId} open={stats.sources.length===1}><summary><div><strong>{accountName(data,source.accountId)}</strong><span>{source.items.length} {source.items.length===1?'Transfer':'Transfers'} · {percent(source.annual,stats.annual)} des ausgewählten Volumens · <b>{percentValue(source.incomeRatio)} der Einnahmen</b></span></div><div className="transfer-source-total"><strong>{formatMoney(source.annual)} / Jahr</strong><small>{formatMoney(source.monthly)} Ø / Monat</small></div><span className="accordion-chevron" aria-hidden="true">⌄</span></summary><div className="transfer-source-body"><div className="transfer-income-ratio-strip"><div><span>Echte Einnahmen</span><strong>{formatMoney(source.incomeAnnual)} / Jahr</strong></div><div><span>Ausgewählte Transfers</span><strong>{formatMoney(source.annual)} / Jahr</strong></div><div><span>Transferquote</span><strong>{percentValue(source.incomeRatio)}</strong></div></div><div className="transfer-target-distribution">{source.targets.map(target=><div className="transfer-target-row" key={target.accountId}><div><strong>{accountName(data,target.accountId)}</strong><span>{target.items.length} {target.items.length===1?'Position':'Positionen'}</span></div><div className="transfer-target-bar"><span style={{width:`${Math.max(target.annual/source.annual*100,1)}%`}}/></div><div><strong>{formatMoney(target.annual)}</strong><span>{percent(target.annual,source.annual)}</span></div></div>)}</div><div className="transfer-analysis-detail-list">{source.items.sort((a,b)=>b.annual-a.annual).map(item=><details className="transfer-analysis-detail" key={item.id}><summary><div><strong>{item.name}</strong><span>{accountName(data,item.fromAccountId)} → {accountName(data,item.toAccountId)}</span></div><div><strong>{formatMoney(item.annual)} / Jahr</strong><small>{percentValue(item.incomeRatio)} der Einnahmen der Quelle</small></div><span className="accordion-chevron" aria-hidden="true">⌄</span></summary><div className="transfer-analysis-detail-body"><span className={`transfer-class-badge ${item.classification.key}`}>{item.classification.label}</span><div><span>Aktuell gültiger Betrag je Intervall</span><strong>{formatMoney(amountChangeState(item).currentAmount)}</strong></div><div><span>Intervall</span><strong>{intervalLabel(item)}</strong></div><div><span>Fälligkeit</span><strong>{movementDueText(item)}</strong></div><div><span>Personen</span><strong>{transferPeopleLabel(data,item)}</strong></div><div><span>Kategorie</span><strong>{categoryName(data,item.categoryId)}</strong></div><div><span>Anteil dieser Quelle</span><strong>{percent(item.annual,source.annual)}</strong></div><div><span>Echte Einnahmen Quelle</span><strong>{formatMoney(item.sourceIncomeAnnual)} / Jahr</strong></div><div><span>Transfer / Einnahmen Quelle</span><strong>{percentValue(item.incomeRatio)}</strong></div>{(item.amountChanges||[]).length>0&&<div className="transfer-analysis-changes"><AmountChangeTimeline item={item}/></div>}</div></details>)}</div></div></details>)}</div></section>
|
||||||
|
|
||||||
<section className="panel transfer-target-overview"><div className="section-head"><div><h2>Verteilung auf die Zielkonten</h2><p>Wie viel des ausgewählten Transfervolumens insgesamt auf jedem Zielkonto landet. Zusätzlich siehst du, wie groß dieser Transferzufluss im Verhältnis zu den echten Einnahmen des Zielkontos ist.</p></div><StatsInfoButton title="Transferzufluss im Verhältnis zu Ziel-Einnahmen"><p>Diese Zielquote ist eine Ergänzung zur Quellquote: <strong>eingehende ausgewählte Transfers ÷ echte Einnahmen des Zielkontos × 100.</strong></p><p>Hat das Zielkonto keine echten Einnahmen, wird keine Prozentquote berechnet. Das ist kein Fehler, sondern bedeutet, dass das Konto ausschließlich oder zusätzlich durch Transfers gespeist wird.</p></StatsInfoButton></div><div className="transfer-target-cards">{stats.targets.map(target=><div className="transfer-target-card" key={target.accountId}><div><strong>{accountName(data,target.accountId)}</strong><span>{target.items.length} {target.items.length===1?'Transfer':'Transfers'}</span></div><strong>{formatMoney(target.annual)} / Jahr</strong><div className="transfer-target-card-foot"><span>{formatMoney(target.monthly)} Ø / Monat</span><b>{percent(target.annual,stats.annual)}</b></div><div className="transfer-target-income-ratio"><span>Echte Einnahmen: {formatMoney(target.incomeAnnual)}</span><strong>Transferzufluss / Einnahmen: {percentValue(target.incomeRatio)}</strong></div></div>)}</div></section>
|
<section className="panel transfer-target-overview"><div className="section-head"><div><h2>Verteilung auf die Zielkonten</h2><p>Wie viel des ausgewählten Transfervolumens insgesamt auf jedem Zielkonto landet. Zusätzlich siehst du, wie groß dieser Transferzufluss im Verhältnis zu den echten Einnahmen des Zielkontos ist.</p></div><StatsInfoButton title="Transferzufluss im Verhältnis zu Ziel-Einnahmen"><p>Diese Zielquote ist eine Ergänzung zur Quellquote: <strong>eingehende ausgewählte Transfers ÷ echte Einnahmen des Zielkontos × 100.</strong></p><p>Hat das Zielkonto keine echten Einnahmen, wird keine Prozentquote berechnet. Das ist kein Fehler, sondern bedeutet, dass das Konto ausschließlich oder zusätzlich durch Transfers gespeist wird.</p></StatsInfoButton></div><div className="transfer-target-cards">{stats.targets.map(target=><div className="transfer-target-card" key={target.accountId}><div><strong>{accountName(data,target.accountId)}</strong><span>{target.items.length} {target.items.length===1?'Transfer':'Transfers'}</span></div><strong>{formatMoney(target.annual)} / Jahr</strong><div className="transfer-target-card-foot"><span>{formatMoney(target.monthly)} Ø / Monat</span><b>{percent(target.annual,stats.annual)}</b></div><div className="transfer-target-income-ratio"><span>Echte Einnahmen: {formatMoney(target.incomeAnnual)}</span><strong>Transferzufluss / Einnahmen: {percentValue(target.incomeRatio)}</strong></div></div>)}</div></section>
|
||||||
|
|
||||||
@@ -1368,16 +1399,18 @@ function EditorDialog({dialog,data,onClose,onSave}){
|
|||||||
try{await onSave(next);}catch(e){setLocalError(e.message);}
|
try{await onSave(next);}catch(e){setLocalError(e.message);}
|
||||||
}
|
}
|
||||||
const simpleType=type==='accounts'||type==='categories'||type==='persons';
|
const simpleType=type==='accounts'||type==='categories'||type==='persons';
|
||||||
|
const editorBaseAmount=Number.isFinite(parseAmount(amount))?parseAmount(amount):Number(item?.amount||0);
|
||||||
|
const editorChanges=amountChangeState({amount:editorBaseAmount,amountChanges});
|
||||||
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>
|
||||||
{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>}
|
{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==='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>}</>}
|
{!simpleType&&<><label><span>{editorChanges.applied.length?'Ursprünglicher Basisbetrag':'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>{editorChanges.applied.length>0&&<small className="field-help">Dieser Betrag gilt nur für den Zeitraum vor der ersten bereits wirksamen Änderung. Der aktuell gültige Betrag wird aus dem Verlauf ermittelt.</small>}</label>{editorChanges.currentSince&&<div className="editor-current-amount"><span>Aktuell gültiger Betrag</span><strong>{formatMoney(editorChanges.currentAmount)}</strong><small>seit {formatDate(editorChanges.currentSince)}</small></div>}<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')&&<><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')&&<>
|
{(type==='incomes'||type==='expenses'||type==='transfers')&&<>
|
||||||
<PersonMultiSelect persons={data.persons||[]} selectedIds={personIds} onChange={setPersonIds}/>
|
<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>}
|
{!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'||type==='transfers')&&interval!=='once'&&<section className="amount-change-panel"><div className="amount-change-head"><div><strong>Geplante Betragsänderungen</strong><span>Ein neuer Betrag gilt ab dem Monat des gewählten Datums. Der Basisbetrag oben bleibt für frühere Monate erhalten.</span></div><span className="change-count">{amountChanges.length}</span></div>{amountChanges.length>0&&<div className="amount-change-list">{amountChanges.map(c=><div className="amount-change-row" key={c.id}><div><span>ab {formatDate(c.effectiveFrom)}</span><strong>{formatMoney(c.amount)}</strong></div><button type="button" className="danger" onClick={()=>setAmountChanges(amountChanges.filter(x=>x.id!==c.id))}>Entfernen</button></div>)}</div>}<div className="amount-change-add"><label><span>Wirksam ab</span><input type="date" value={changeDate} onChange={e=>setChangeDate(e.target.value)}/></label><label><span>Neuer Betrag</span><div className="money-input"><input inputMode="decimal" value={changeAmount} onChange={e=>setChangeAmount(e.target.value)} placeholder="0,00"/><span>€</span></div></label><button type="button" className="secondary" onClick={addAmountChange}>+ Änderung</button></div></section>}
|
{(type==='incomes'||type==='expenses'||type==='transfers')&&interval!=='once'&&<section className="amount-change-panel"><div className="amount-change-head"><div><strong>Betragsverlauf & Planung</strong><span>Bereits erreichte Stichtage sind Historie und bestimmen den aktuell gültigen Betrag. Nur spätere Stichtage gelten noch als geplante Änderungen.</span></div><span className="change-count">{editorChanges.future.length}</span></div>{editorChanges.applied.length>0&&<div className="amount-change-group"><span className="amount-change-group-title">Historische / bereits wirksame Änderungen</span><div className="amount-change-list">{editorChanges.applied.map((c,index)=><div className="amount-change-row" key={c.id}><div><span>ab {formatDate(c.effectiveFrom)} <b className={`change-status ${index===editorChanges.applied.length-1?'current':'history'}`}>{index===editorChanges.applied.length-1?'aktuell':'Historie'}</b></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>}{editorChanges.future.length>0&&<div className="amount-change-group"><span className="amount-change-group-title">Geplante Änderungen</span><div className="amount-change-list">{editorChanges.future.map(c=><div className="amount-change-row" key={c.id}><div><span>ab {formatDate(c.effectiveFrom)} <b className="change-status planned">geplant</b></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>}<div className="amount-change-add"><label><span>Wirksam ab</span><input type="date" value={changeDate} onChange={e=>setChangeDate(e.target.value)}/></label><label><span>Neuer Betrag</span><div className="money-input"><input inputMode="decimal" value={changeAmount} onChange={e=>setChangeAmount(e.target.value)} placeholder="0,00"/><span>€</span></div></label><button type="button" className="secondary" onClick={addAmountChange}>+ Änderung</button></div></section>}
|
||||||
{type==='expenses'&&<button type="button" className={`reminder-toggle ${pushoverReminder?'active':''}`} aria-pressed={pushoverReminder} onClick={()=>setPushoverReminder(x=>!x)}><span className={`checkbox-mark ${pushoverReminder?'checked':''}`}>{pushoverReminder?'✓':''}</span><span><strong>Pushover-Erinnerung</strong><small>Am 1. des Fälligkeitsmonats mit Betrag und Konto erinnern.</small></span></button>}
|
{type==='expenses'&&<button type="button" className={`reminder-toggle ${pushoverReminder?'active':''}`} aria-pressed={pushoverReminder} onClick={()=>setPushoverReminder(x=>!x)}><span className={`checkbox-mark ${pushoverReminder?'checked':''}`}>{pushoverReminder?'✓':''}</span><span><strong>Pushover-Erinnerung</strong><small>Am 1. des Fälligkeitsmonats mit Betrag und Konto erinnern.</small></span></button>}
|
||||||
{type==='transfers'&&<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button type="submit" className="primary">Speichern</button></div></form></div>;
|
{type==='transfers'&&<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button type="submit" className="primary">Speichern</button></div></form></div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1566,3 +1566,22 @@ html.modal-open, body.modal-open { overflow:hidden !important; overscroll-behavi
|
|||||||
|
|
||||||
.stats-context-help { display:grid; gap:7px; padding-top:10px; border-top:1px solid #314157; }
|
.stats-context-help { display:grid; gap:7px; padding-top:10px; border-top:1px solid #314157; }
|
||||||
.stats-context-help > strong { color:#eef2f8; }
|
.stats-context-help > strong { color:#eef2f8; }
|
||||||
|
|
||||||
|
|
||||||
|
/* v3.36: aktive Betragsänderungen werden Historie statt Planung */
|
||||||
|
.history-badge { display:inline-flex;align-items:center;margin-left:6px;padding:3px 6px;border-radius:999px;font-size:8px;font-weight:800;white-space:nowrap;vertical-align:middle;background:#16271f;border:1px solid #315c47;color:#9ad7b4; }
|
||||||
|
.amount-timeline-stack { display:grid;gap:8px;margin-top:10px; }
|
||||||
|
.movement-change-preview.current { background:#10241b;border-color:#315c47; }
|
||||||
|
.movement-change-preview.current > span,.movement-change-preview.current > div span { color:#a9e2bf; }
|
||||||
|
.movement-change-preview.history { background:#121923;border-color:#344154; }
|
||||||
|
.editor-current-amount { display:grid;grid-template-columns:1fr auto;gap:2px 12px;align-items:center;padding:11px 12px;border:1px solid #315c47;border-radius:10px;background:#10241b;margin-top:-2px; }
|
||||||
|
.editor-current-amount span { color:#9bb2a4;font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.05em; }
|
||||||
|
.editor-current-amount strong { font-size:16px;color:#d8f5e3; }
|
||||||
|
.editor-current-amount small { grid-column:1/-1;color:#92a99a;font-size:9px; }
|
||||||
|
.amount-change-group { display:grid;gap:7px;margin-top:10px; }
|
||||||
|
.amount-change-group-title { color:#aebbe0;font-size:9px;font-weight:850;text-transform:uppercase;letter-spacing:.04em; }
|
||||||
|
.change-status { display:inline-flex;margin-left:6px;padding:2px 5px;border-radius:999px;font-size:7px;text-transform:uppercase;letter-spacing:.03em; }
|
||||||
|
.change-status.current { background:#173325;color:#a9e2bf;border:1px solid #315c47; }
|
||||||
|
.change-status.history { background:#17202c;color:#a9b6c8;border:1px solid #344154; }
|
||||||
|
.change-status.planned { background:#1d2848;color:#b4c0ff;border:1px solid #41558b; }
|
||||||
|
@media(max-width:700px){.history-badge{font-size:7px;padding:3px 6px;margin-left:0}.editor-current-amount{grid-template-columns:1fr auto}}
|
||||||
|
|||||||
Reference in New Issue
Block a user