This commit is contained in:
2026-08-18 09:00:00 +02:00
parent cf05c7a733
commit f371a3bb25
4 changed files with 94 additions and 3 deletions
+5
View File
@@ -92,3 +92,8 @@ Eingänge, Ausgänge und Transfers unterstützen `monthly`, `quarterly`, `semian
Der eingegebene Betrag ist immer der Betrag pro Intervall. Für Salden kann FixFin periodische Werte automatisch auf einen Monatsdurchschnitt umrechnen. Diese Berücksichtigung lässt sich global in der Übersicht ein-/ausschalten. Der eingegebene Betrag ist immer der Betrag pro Intervall. Für Salden kann FixFin periodische Werte automatisch auf einen Monatsdurchschnitt umrechnen. Diese Berücksichtigung lässt sich global in der Übersicht ein-/ausschalten.
Für nicht-monatliche Positionen kann optional ein Fälligkeitsmonat angegeben werden. Bei vierteljährlichen und halbjährlichen Positionen ist dies der erste Fälligkeitsmonat. Die Jahresansicht verteilt die Position anschließend auf die tatsächlichen Fälligkeitsmonate. Positionen ohne Monat bleiben für die Monatsdurchschnittsrechnung nutzbar, werden in der Jahresprognose aber separat als nicht zugeordnet angezeigt. Für nicht-monatliche Positionen kann optional ein Fälligkeitsmonat angegeben werden. Bei vierteljährlichen und halbjährlichen Positionen ist dies der erste Fälligkeitsmonat. Die Jahresansicht verteilt die Position anschließend auf die tatsächlichen Fälligkeitsmonate. Positionen ohne Monat bleiben für die Monatsdurchschnittsrechnung nutzbar, werden in der Jahresprognose aber separat als nicht zugeordnet angezeigt.
## Jahresansicht auf Mobilgeräten
Die Jahresprognose verwendet kompakte Monats-Akkordeons. Monat, Eingänge, Ausgänge und Saldo sind immer sichtbar. Fällige Einzelpositionen werden erst beim Aufklappen angezeigt. Die mobile Bottom-Navigation bleibt auf allen Ansichten identisch.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "fixfin", "name": "fixfin",
"version": "1.2.0", "version": "1.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+24 -2
View File
@@ -139,8 +139,30 @@ function Entries({type,items,accounts,onEdit,onDelete}){const accountName=id=>ac
function Transfers({data,onEdit,onDelete}){const accountName=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt';return <section className="panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Verschiebungen zwischen eigenen Konten. Global saldoneutral.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<div className="table-wrap"><table><thead><tr><th>Bezeichnung</th><th>Von</th><th>Nach</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=><tr key={x.id}><td><strong>{x.name}</strong></td><td>{accountName(x.fromAccountId)}</td><td>{accountName(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:''}</td><td className="number">{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>} function Transfers({data,onEdit,onDelete}){const accountName=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt';return <section className="panel"><div className="section-head"><div><h2>Fixe Transfers</h2><p>Verschiebungen zwischen eigenen Konten. Global saldoneutral.</p></div></div>{data.transfers.length===0?<Empty text="Noch keine Transfers vorhanden."/>:<div className="table-wrap"><table><thead><tr><th>Bezeichnung</th><th>Von</th><th>Nach</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{data.transfers.map(x=><tr key={x.id}><td><strong>{x.name}</strong></td><td>{accountName(x.fromAccountId)}</td><td>{accountName(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:''}</td><td className="number">{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>}
function YearPage({forecast}){ function YearPage({forecast}){
return <div className="content-stack"><section className="summary-grid year-summary"><Metric label="Einnahmen / Jahr" value={forecast.yearIncome} positive/><Metric label="Ausgaben / Jahr" value={forecast.yearExpense} negative/><Metric label="Saldo / Jahr" value={forecast.yearIncome-forecast.yearExpense} positive={forecast.yearIncome>=forecast.yearExpense} negative={forecast.yearIncome<forecast.yearExpense}/><Metric label="Nicht zugeordnet" value={forecast.unassigned.length}/></section> const kindLabel={income:'Eingang',expense:'Ausgang',transfer:'Transfer'};
<section className="panel"><div className="section-head"><div><h2>Jahresprognose</h2><p>Tatsächliche Fälligkeiten nach Monat. Transfers sind im Gesamtsaldo neutral.</p></div></div><div className="table-wrap"><table className="year-table"><thead><tr><th>Monat</th><th className="number">Eingänge</th><th className="number">Ausgänge</th><th className="number">Saldo</th><th>Fällige Positionen</th></tr></thead><tbody>{forecast.rows.map(r=><tr key={r.name}><td><strong>{r.name}</strong></td><td className="number good-text">+{formatMoney(r.income)}</td><td className="number bad-text">{formatMoney(r.expense)}</td><td className={`number ${r.balance>=0?'good-text':'bad-text'}`}>{formatMoney(r.balance)}</td><td><div className="due-items">{r.items.length===0?<span className="muted">Keine</span>:r.items.map((x,i)=><span key={`${x.id}-${i}`} className={`due-chip ${x.kind}`}>{x.name} · {formatMoney(x.amount)}</span>)}</div></td></tr>)}</tbody></table></div></section> const kindSign={income:'+',expense:'',transfer:''};
return <div className="content-stack year-page">
<section className="summary-grid year-summary"><Metric label="Einnahmen / Jahr" value={forecast.yearIncome} positive/><Metric label="Ausgaben / Jahr" value={forecast.yearExpense} negative/><Metric label="Saldo / Jahr" value={forecast.yearIncome-forecast.yearExpense} positive={forecast.yearIncome>=forecast.yearExpense} negative={forecast.yearIncome<forecast.yearExpense}/><Metric label="Nicht zugeordnet" value={forecast.unassigned.length}/></section>
<section className="panel year-forecast-panel"><div className="section-head"><div><h2>Jahresprognose</h2><p>Monatliche Basiswerte auf einen Blick. Details bei Bedarf ausklappen.</p></div></div>
<div className="year-month-list">{forecast.rows.map(r=><details className="year-month-card" key={r.name}>
<summary>
<div className="year-month-heading"><strong>{r.name}</strong><span>{r.items.length===0?'Keine Fälligkeiten':`${r.items.length} ${r.items.length===1?'Position':'Positionen'}`}</span></div>
<div className="year-month-basics">
<div><span>Eingänge</span><strong className="good-text">+{formatMoney(r.income)}</strong></div>
<div><span>Ausgänge</span><strong className="bad-text">{formatMoney(r.expense)}</strong></div>
<div className="year-month-balance"><span>Saldo</span><strong className={r.balance>=0?'good-text':'bad-text'}>{r.balance>=0?'+':''}{formatMoney(r.balance)}</strong></div>
</div>
<span className="accordion-chevron" aria-hidden="true"></span>
</summary>
<div className="year-month-details">
{r.items.length===0?<div className="year-detail-empty">In diesem Monat sind keine einzelnen Fixpositionen fällig.</div>:r.items.map((x,i)=><div className="year-detail-row" key={`${x.id}-${i}`}>
<div className="year-detail-name"><span className={`year-kind ${x.kind}`}>{kindLabel[x.kind]}</span><strong>{x.name}</strong></div>
<span className={`year-detail-amount ${x.kind==='income'?'good-text':x.kind==='expense'?'bad-text':''}`}>{kindSign[x.kind]}{formatMoney(x.amount)}</span>
</div>)}
{(r.transferIn>0||r.transferOut>0)&&<div className="year-transfer-note">Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie verändern den Gesamtsaldo nicht.</div>}
</div>
</details>)}</div>
</section>
{forecast.unassigned.length>0&&<section className="panel warning-panel"><div className="section-head"><div><h2>Periodische Positionen ohne Monat</h2><p>Diese Werte können in den Monats-Saldo eingerechnet werden, sind aber keiner konkreten Jahresprognose zugeordnet.</p></div></div><div className="due-items">{forecast.unassigned.map(x=><span className={`due-chip ${x.kind}`} key={`${x.kind}-${x.id}`}>{x.name} · {intervals[x.interval].label} · {formatMoney(x.amount)}</span>)}</div></section>} {forecast.unassigned.length>0&&<section className="panel warning-panel"><div className="section-head"><div><h2>Periodische Positionen ohne Monat</h2><p>Diese Werte können in den Monats-Saldo eingerechnet werden, sind aber keiner konkreten Jahresprognose zugeordnet.</p></div></div><div className="due-items">{forecast.unassigned.map(x=><span className={`due-chip ${x.kind}`} key={`${x.kind}-${x.id}`}>{x.name} · {intervals[x.interval].label} · {formatMoney(x.amount)}</span>)}</div></section>}
</div>; </div>;
} }
+64
View File
@@ -148,3 +148,67 @@ code { color:#b8c3ff; }
.warning-panel { border-color:#5a4e2b; } .warning-panel { border-color:#5a4e2b; }
@media (max-width:760px) { .toggle-panel { align-items:flex-start; } .year-summary { grid-template-columns:1fr 1fr; } .mobile-nav { grid-template-columns:repeat(7,1fr); } } @media (max-width:760px) { .toggle-panel { align-items:flex-start; } .year-summary { grid-template-columns:1fr 1fr; } .mobile-nav { grid-template-columns:repeat(7,1fr); } }
@media (max-width:430px) { .year-summary { grid-template-columns:1fr; } } @media (max-width:430px) { .year-summary { grid-template-columns:1fr; } }
/* v1.3: mobile Jahresansicht als Akkordeon */
.year-month-list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
.year-month-card { background:#0d131c; border:1px solid #29364a; border-radius:14px; overflow:hidden; }
.year-month-card[open] { border-color:#46577a; background:#0f1621; }
.year-month-card summary { list-style:none; cursor:pointer; display:grid; grid-template-columns:minmax(105px,.75fr) minmax(0,2fr) 22px; gap:16px; align-items:center; padding:15px 16px; user-select:none; }
.year-month-card summary::-webkit-details-marker { display:none; }
.year-month-heading { min-width:0; }
.year-month-heading strong { display:block; font-size:15px; }
.year-month-heading span { display:block; color:var(--muted); font-size:10px; margin-top:3px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.year-month-basics { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; min-width:0; }
.year-month-basics div { min-width:0; }
.year-month-basics span { display:block; color:#718096; font-size:9px; text-transform:uppercase; letter-spacing:.055em; font-weight:800; margin-bottom:3px; }
.year-month-basics strong { display:block; font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.accordion-chevron { color:#8793a4; font-size:20px; line-height:1; text-align:center; transition:transform .18s ease; }
.year-month-card[open] .accordion-chevron { transform:rotate(180deg); }
.year-month-details { border-top:1px solid #202a39; padding:7px 16px 11px; }
.year-detail-row { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 0; border-bottom:1px solid #1e2937; }
.year-detail-row:last-of-type { border-bottom:0; }
.year-detail-name { display:flex; align-items:center; gap:8px; min-width:0; }
.year-detail-name strong { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.year-kind { flex:0 0 auto; border-radius:999px; padding:3px 6px; font-size:8px; font-weight:850; text-transform:uppercase; letter-spacing:.04em; background:#1c2635; border:1px solid #304057; color:#b8c5d7; }
.year-kind.income { border-color:#245947; color:#7de0b5; background:#112c24; }
.year-kind.expense { border-color:#67333b; color:#ff9aa2; background:#321a20; }
.year-kind.transfer { border-color:#414d7d; color:#adb9ff; background:#1c2340; }
.year-detail-amount { flex:0 0 auto; font-size:12px; font-weight:800; white-space:nowrap; }
.year-detail-empty { color:var(--muted); font-size:12px; padding:10px 0 6px; }
.year-transfer-note { color:#8793a4; font-size:10px; border-top:1px dashed #2d394b; padding-top:9px; margin-top:5px; }
@media (max-width: 900px) {
.year-month-list { grid-template-columns:1fr; }
}
@media (max-width: 760px) {
.year-page { min-width:0; }
.year-forecast-panel { padding:14px; }
.year-month-list { gap:9px; }
.year-month-card summary { grid-template-columns:1fr 22px; gap:9px 10px; padding:13px 12px; }
.year-month-heading { grid-column:1; }
.accordion-chevron { grid-column:2; grid-row:1; align-self:center; }
.year-month-basics { grid-column:1 / -1; grid-row:2; gap:6px; }
.year-month-basics div { background:#101823; border:1px solid #202c3c; border-radius:9px; padding:8px; }
.year-month-basics strong { font-size:12px; }
.year-month-basics span { font-size:8px; }
.year-month-balance { background:#141c2c !important; border-color:#2b3850 !important; }
.year-month-details { padding:5px 12px 9px; }
.year-detail-row { padding:9px 1px; }
.year-summary { grid-template-columns:1fr 1fr; gap:8px; }
.year-summary .metric { min-height:82px; padding:12px; }
.year-summary .metric strong { font-size:18px; }
.mobile-nav { width:100vw; max-width:100vw; overflow-x:auto; overscroll-behavior-x:contain; scrollbar-width:none; }
.mobile-nav::-webkit-scrollbar { display:none; }
.mobile-nav button { min-width:50px; }
}
@media (max-width: 430px) {
.year-summary { grid-template-columns:1fr 1fr; }
.year-month-heading strong { font-size:14px; }
.year-detail-name strong { font-size:11px; }
.year-kind { font-size:7px; padding:3px 5px; }
}
@media (max-width: 760px) {
.mobile-nav { grid-template-columns:repeat(7,minmax(50px,1fr)); }
}