This commit is contained in:
2026-08-18 23:30:33 +02:00
parent 52b46f09a4
commit 4f5e300b4a
4 changed files with 109 additions and 13 deletions
+24 -5
View File
@@ -10,7 +10,7 @@ const intervals = {
semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 },
yearly: { label: 'Jährlich', divisor: 12, step: 12 },
};
const emptyData = { version: 6, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], incomes: [], expenses: [], transfers: [], scenarios: [] };
const emptyData = { version: 7, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], incomes: [], expenses: [], transfers: [], scenarios: [] };
function formatMoney(value) { return euro.format(Number(value || 0)); }
function parseAmount(value) {
@@ -335,9 +335,9 @@ function Entries({type,items,accounts,categories,onEdit,onDuplicate,onDelete}){
const isIncome=type==='incomes';
const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt';
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}</h2><p>Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.</p></div></div>{items.length===0?<Empty text={`Noch keine ${isIncome?'Eingänge':'Ausgänge'} vorhanden.`}/>:<>
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Kategorie</th><th>Konto</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{items.map(x=><tr key={x.id}><td><strong>{x.name}</strong></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className={`number ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':''}{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div>
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Kategorie</th><th>Konto</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{items.map(x=><tr key={x.id}><td><strong>{x.name}</strong>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder</span>}</td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className={`number ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':''}{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div>
<div className="movement-mobile-list">{items.map(x=><article className="movement-card" key={x.id}>
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></div><strong className={isIncome?'good-text':'bad-text'}>{isIncome?'+':''}{formatMoney(x.amount)}</strong></div>
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder am 1.</span>}</div><strong className={isIncome?'good-text':'bad-text'}>{isIncome?'+':''}{formatMoney(x.amount)}</strong></div>
<div className="movement-card-grid">
<div><span>Konto</span><strong>{accountNameLocal(x.accountId)}</strong></div>
<div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div>
@@ -493,6 +493,22 @@ function ScenarioAdjustmentDialog({data,item,onClose,onSave}){
return <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}><div className="modal-head"><div><span className="eyebrow">SZENARIO</span><h2>{item?'Anpassung bearbeiten':'Anpassung hinzufügen'}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>{error&&<div className="alert">{error}</div>}<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder="z. B. Neue Wohnung"/></label><label><span>Art</span><select value={type} onChange={e=>setType(e.target.value)}><option value="income">Eingang</option><option value="expense">Ausgang</option><option value="transfer">Transfer</option></select></label><label><span>Betrag / Monat</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span></span></div></label>{type!=='transfer'?<label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>:<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button className="primary" type="submit">Speichern</button></div></form></div>;
}
function PushoverSettings(){
const [status,setStatus]=useState({loading:true,configured:false,userMasked:''});
const [user,setUser]=useState(''); const [token,setToken]=useState(''); const [busy,setBusy]=useState(false); const [message,setMessage]=useState(''); const [error,setError]=useState('');
async function load(){try{const r=await fetch('/api/pushover/config');const body=await r.json();if(!r.ok)throw new Error(body.error||'Pushover-Status konnte nicht geladen werden.');setStatus({...body,loading:false});}catch(e){setError(e.message);setStatus(x=>({...x,loading:false}));}}
useEffect(()=>{load();},[]);
async function save(e){e.preventDefault();setBusy(true);setError('');setMessage('');try{const r=await fetch('/api/pushover/config',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:user.trim(),token:token.trim()})});const body=await r.json();if(!r.ok)throw new Error(body.error||'Pushover konnte nicht gespeichert werden.');setStatus({...body,loading:false});setUser('');setToken('');setMessage('Zugangsdaten gespeichert.');}catch(e){setError(e.message);}finally{setBusy(false);}}
async function test(){setBusy(true);setError('');setMessage('');try{const r=await fetch('/api/pushover/test',{method:'POST'});const body=await r.json();if(!r.ok)throw new Error(body.error||'Testnachricht fehlgeschlagen.');setMessage('Testnachricht wurde gesendet.');}catch(e){setError(e.message);}finally{setBusy(false);}}
async function remove(){if(!confirm('Pushover-Zugangsdaten wirklich entfernen?'))return;setBusy(true);setError('');setMessage('');try{const r=await fetch('/api/pushover/config',{method:'DELETE'});const body=await r.json();if(!r.ok)throw new Error(body.error||'Zugangsdaten konnten nicht entfernt werden.');setStatus({...body,loading:false});setMessage('Pushover-Zugangsdaten entfernt.');}catch(e){setError(e.message);}finally{setBusy(false);}}
return <section className="panel pushover-settings"><div className="section-head"><div><h2>Pushover</h2><p>Reminder für markierte Ausgaben werden am 1. ihres Fälligkeitsmonats serverseitig verschickt.</p></div><span className={`pushover-status ${status.configured?'configured':''}`}>{status.loading?'Prüft …':status.configured?'Konfiguriert':'Nicht konfiguriert'}</span></div>
{status.configured&&<div className="pushover-current"><span>User/Group Key</span><strong>{status.userMasked}</strong></div>}
{error&&<div className="alert compact-alert">{error}</div>}{message&&<div className="success-note">{message}</div>}
<form className="pushover-form" onSubmit={save}><label><span>User / Group Key</span><input autoComplete="off" value={user} onChange={e=>setUser(e.target.value)} placeholder={status.configured?'Zum Ändern neu eingeben':'30-stelliger User/Group Key'}/></label><label><span>Application API Token</span><input type="password" autoComplete="new-password" value={token} onChange={e=>setToken(e.target.value)} placeholder={status.configured?'Zum Ändern neu eingeben':'30-stelliger API Token'}/></label><div className="button-row pushover-actions"><button className="primary" type="submit" disabled={busy}>{status.configured?'Zugangsdaten ändern':'Speichern'}</button>{status.configured&&<><button className="secondary" type="button" disabled={busy} onClick={test}>Test senden</button><button className="danger" type="button" disabled={busy} onClick={remove}>Entfernen</button></>}</div></form>
<p className="pushover-help">Die Zugangsdaten werden separat serverseitig gespeichert und sind weder Teil des JSON-Exports noch der PWA-Daten.</p>
</section>;
}
function DataPage({data,importFile}){
const summary=[
['Konten',data.accounts.length],
@@ -504,6 +520,7 @@ function DataPage({data,importFile}){
];
const json=JSON.stringify(data,null,2);
return <div className="content-stack data-page">
<PushoverSettings/>
<section className="panel data-backup-panel"><div className="section-head"><div><h2>Daten sichern</h2><p>Alle Daten liegen in einer JSON-Datei. Vor jedem Speichern wird serverseitig automatisch eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row data-actions"><a className="primary button-link" href="/api/export">JSON herunterladen</a><label className="secondary button-link">JSON importieren<input type="file" accept="application/json,.json" hidden onChange={e=>e.target.files?.[0]&&importFile(e.target.files[0])}/></label></div></section>
<section className="panel data-status-panel"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>Kurzübersicht über den Inhalt deiner FixFin-Datei.</p></div></div>
<div className="data-summary-grid">{summary.map(([label,value])=><div className="data-summary-card" key={label}><span>{label}</span><strong>{Number(value).toLocaleString('de-DE')}</strong></div>)}</div>
@@ -516,7 +533,7 @@ function Empty({text}){return <div className="empty">{text}</div>}
function EditorDialog({dialog,data,onClose,onSave}){
const type=dialog.type,item=dialog.item; const editing=Boolean(item?.id);
const [name,setName]=useState(item?.name||''),[amount,setAmount]=useState(item?.amount??''),[accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''),[fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''),[toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''),[interval,setInterval]=useState(item?.interval||'monthly'),[dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''),[categoryId,setCategoryId]=useState(item?.categoryId||''),[newCategoryName,setNewCategoryName]=useState(''),[localError,setLocalError]=useState('');
const [name,setName]=useState(item?.name||''),[amount,setAmount]=useState(item?.amount??''),[accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''),[fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''),[toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''),[interval,setInterval]=useState(item?.interval||'monthly'),[dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''),[categoryId,setCategoryId]=useState(item?.categoryId||''),[newCategoryName,setNewCategoryName]=useState(''),[pushoverReminder,setPushoverReminder]=useState(item?.pushoverReminder===true),[localError,setLocalError]=useState('');
const labels={accounts:'Konto',categories:'Kategorie',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'};
async function submit(e){
e.preventDefault();setLocalError(''); const numeric=parseAmount(amount);
@@ -524,6 +541,7 @@ function EditorDialog({dialog,data,onClose,onSave}){
if(type!=='accounts'&&type!=='categories'&&(numeric<0||!Number.isFinite(numeric)))return setLocalError('Bitte einen gültigen Betrag eingeben.');
if(!['accounts','categories'].includes(type)&&data.accounts.length===0)return setLocalError('Bitte zuerst ein Konto 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.');
let next=structuredClone(data); const id=editing?item.id:uid(); let value;
if(type==='accounts') 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()}; }
@@ -531,7 +549,7 @@ function EditorDialog({dialog,data,onClose,onSave}){
const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null)};
let selectedCategoryId=categoryId||null;
if(categoryId==='__new__'){const categoryNameValue=newCategoryName.trim();if(!categoryNameValue)return setLocalError('Bitte einen Namen für die neue Kategorie eingeben.');const existing=next.categories.find(c=>c.name.trim().toLocaleLowerCase('de-DE')===categoryNameValue.toLocaleLowerCase('de-DE'));if(existing) selectedCategoryId=existing.id;else { selectedCategoryId=uid(); next.categories.push({id:selectedCategoryId,name:categoryNameValue}); }}
if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId}; else value={...common,accountId,categoryId:selectedCategoryId};
if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId}; else if(type==='expenses') value={...common,accountId,categoryId:selectedCategoryId,pushoverReminder}; else value={...common,accountId,categoryId:selectedCategoryId};
}
next[type]=editing?next[type].map(x=>x.id===item.id?value:x):[...next[type],value];
try{await onSave(next);}catch(e){setLocalError(e.message);}
@@ -541,6 +559,7 @@ function EditorDialog({dialog,data,onClose,onSave}){
{!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>}</>}
{(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')&&<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==='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>;
}
+35
View File
@@ -863,3 +863,38 @@ code { color:#b8c3ff; }
.year-flow-breakdown { grid-template-columns:1fr 1fr; gap:6px; }
.year-flow-breakdown > div { padding:8px; }
}
/* v3.15: Pushover-Reminder */
.reminder-badge { display:inline-flex; align-items:center; margin-left:7px; padding:3px 6px; border:1px solid #675c2a; background:#2b2616; color:#e9d98b; border-radius:999px; font-size:8px; font-weight:850; text-transform:uppercase; letter-spacing:.045em; vertical-align:middle; white-space:nowrap; }
.movement-card-head .reminder-badge { margin-left:0; margin-top:5px; width:max-content; }
.reminder-toggle { width:100%; display:flex; align-items:center; gap:11px; text-align:left; border:1px solid #2d394b; background:#101722; color:var(--text); border-radius:12px; padding:11px 12px; }
.reminder-toggle.active { border-color:#665d2c; background:#211f16; }
.checkbox-mark { width:22px; height:22px; flex:0 0 auto; display:grid; place-items:center; border-radius:6px; border:1px solid #46536a; background:#0b111a; color:#101522; font-size:14px; font-weight:950; }
.checkbox-mark.checked { background:#e1ca6c; border-color:#e1ca6c; }
.reminder-toggle > span:last-child { min-width:0; }
.reminder-toggle strong { display:block; font-size:12px; }
.reminder-toggle small { display:block; margin-top:2px; color:var(--muted); font-size:10px; line-height:1.35; }
.pushover-settings .section-head { align-items:center; }
.pushover-status { flex:0 0 auto; border:1px solid #4a3540; background:#231820; color:#d69aa9; border-radius:999px; padding:5px 8px; font-size:9px; font-weight:850; text-transform:uppercase; letter-spacing:.05em; }
.pushover-status.configured { border-color:#275744; background:#11281f; color:#7bd6ac; }
.pushover-current { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:12px; padding:10px 12px; border:1px solid #29364a; background:#0d131c; border-radius:10px; }
.pushover-current span { color:var(--muted); font-size:9px; text-transform:uppercase; letter-spacing:.055em; font-weight:800; }
.pushover-current strong { font-size:11px; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
.pushover-form { display:grid; grid-template-columns:1fr 1fr; gap:10px 12px; }
.pushover-form label { display:grid; gap:5px; }
.pushover-form label span { color:var(--muted); font-size:10px; font-weight:700; }
.pushover-form input { width:100%; background:#0b111a; color:#f5f7fb; border:1px solid #2a374a; border-radius:10px; padding:10px 11px; outline:none; }
.pushover-form input:focus { border-color:var(--accent); box-shadow:0 0 0 3px #8293ff1d; }
.pushover-actions { grid-column:1/-1; margin-top:2px; }
.pushover-actions .danger { border:1px solid #5e3038; background:#2d181d; color:#ff9aa2; }
.pushover-help { margin:10px 0 0; color:var(--muted); font-size:9px; line-height:1.45; }
.compact-alert { margin-bottom:10px; }
.success-note { margin-bottom:10px; padding:9px 11px; border:1px solid #275744; background:#11281f; color:#8de0ba; border-radius:10px; font-size:10px; }
@media (max-width:760px) {
.pushover-settings .section-head { align-items:flex-start; }
.pushover-form { grid-template-columns:1fr; }
.pushover-actions { grid-column:auto; display:grid; grid-template-columns:1fr; }
.pushover-actions button { width:100%; min-height:42px; }
.reminder-toggle { padding:10px; }
}