diff --git a/public/sw.js b/public/sw.js index 41cc831..12b1028 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const SHELL_CACHE='fixfin-shell-v3.14'; +const SHELL_CACHE='fixfin-shell-v3.15'; const DATA_CACHE='fixfin-data-v1'; const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png']; diff --git a/server/index.js b/server/index.js index b794144..460df40 100644 --- a/server/index.js +++ b/server/index.js @@ -6,13 +6,16 @@ import { fileURLToPath } from 'node:url'; const __filename=fileURLToPath(import.meta.url); const __dirname=path.dirname(__filename); const app=express(); const port=Number(process.env.PORT||3000); const dataFile=process.env.DATA_FILE||'/app/data/finance.json'; const dataDir=path.dirname(dataFile); const backupFile=`${dataFile}.bak`; +const pushoverFile=process.env.PUSHOVER_FILE||path.join(dataDir,'pushover.json'); const pushoverStateFile=process.env.PUSHOVER_STATE_FILE||path.join(dataDir,'pushover-state.json'); +const reminderHour=Math.min(23,Math.max(0,Number(process.env.PUSHOVER_REMINDER_HOUR||8))); const PUSHOVER_ENDPOINT='https://api.pushover.net/1/messages.json'; const validIntervals=new Set(['monthly','quarterly','semiannual','yearly']); const validScenarioTypes=new Set(['income','expense','transfer']); -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:[]}); const isFiniteNumber=value=>typeof value==='number'&&Number.isFinite(value); function normalizeMovement(item={}) { return { ...item, interval: validIntervals.has(item.interval)?item.interval:'monthly', dueMonth: item.interval && item.interval!=='monthly' && Number.isInteger(Number(item.dueMonth)) ? Number(item.dueMonth) : null }; } function normalizeCategorizedMovement(item={}) { const normalized=normalizeMovement(item); return { ...normalized, categoryId: typeof item.categoryId==='string' && item.categoryId ? item.categoryId : null }; } +function normalizeExpense(item={}) { return { ...normalizeCategorizedMovement(item), pushoverReminder: item.pushoverReminder===true }; } function normalizeScenarioAdjustment(item={}) { const type=validScenarioTypes.has(item.type)?item.type:'expense'; const base={id:item.id,name:item.name,type,amount:Number(item.amount||0)}; @@ -23,12 +26,12 @@ function normalizeScenario(item={}) { return {id:item.id,name:item.name,createdA function normalizeData(input){ if(!input||typeof input!=='object')return input; return { - version:6, + version:7, settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false}, accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts, categories:Array.isArray(input.categories)?input.categories.map(({id,name})=>({id,name})):[], incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeCategorizedMovement):input.incomes, - expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeCategorizedMovement):input.expenses, + expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeExpense):input.expenses, transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeCategorizedMovement):input.transfers, scenarios:Array.isArray(input.scenarios)?input.scenarios.map(normalizeScenario):[], }; @@ -41,7 +44,7 @@ function validateData(input){ for(const a of input.accounts){if(!a.id||typeof a.id!=='string')throw new Error('Konto ohne gültige ID.');if(!a.name||typeof a.name!=='string')throw new Error('Konto ohne Namen.');if(accountIds.has(a.id))throw new Error('Doppelte Konto-ID.');accountIds.add(a.id);} const categoryIds=new Set(); const categoryNames=new Set(); for(const c of input.categories){if(!c.id||typeof c.id!=='string')throw new Error('Kategorie ohne gültige ID.');if(!c.name||typeof c.name!=='string'||!c.name.trim())throw new Error('Kategorie ohne Namen.');if(categoryIds.has(c.id))throw new Error('Doppelte Kategorie-ID.');const normalizedName=c.name.trim().toLocaleLowerCase('de-DE');if(categoryNames.has(normalizedName))throw new Error(`Kategorie '${c.name}' existiert bereits.`);categoryIds.add(c.id);categoryNames.add(normalizedName);} - for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie bei '${item.name}'.`);} + for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie bei '${item.name}'.`);if(collection==='expenses'&&typeof item.pushoverReminder!=='boolean')throw new Error(`Ungültige Reminder-Einstellung bei '${item.name}'.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval!=='monthly'&&(item.dueMonth===null||item.dueMonth===undefined))throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Fälligkeitsmonat gesetzt sein.`);} for(const t of input.transfers){validateMovement(t);if(!accountIds.has(t.fromAccountId)||!accountIds.has(t.toAccountId))throw new Error(`Unbekanntes Konto beim Transfer '${t.name}'.`);if(t.fromAccountId===t.toAccountId)throw new Error('Quell- und Zielkonto müssen verschieden sein.');if(t.categoryId!==null&&t.categoryId!==undefined&&!categoryIds.has(t.categoryId))throw new Error(`Unbekannte Kategorie beim Transfer '${t.name}'.`);} const scenarioIds=new Set(); for(const s of input.scenarios){ @@ -60,8 +63,47 @@ function validateData(input){ } } } + +const pushoverKeyPattern=/^[A-Za-z0-9]{30}$/; +function maskKey(value=''){return value?`${value.slice(0,4)}••••••${value.slice(-4)}`:'';} +async function readJsonFile(file,fallback){try{return JSON.parse(await fs.readFile(file,'utf8'));}catch(error){if(error.code==='ENOENT')return fallback;throw error;}} +async function writePrivateJson(file,value){await fs.mkdir(path.dirname(file),{recursive:true});const tmp=`${file}.${process.pid}.${crypto.randomUUID()}.tmp`;await fs.writeFile(tmp,JSON.stringify(value,null,2)+'\n',{encoding:'utf8',mode:0o600});await fs.rename(tmp,file);} +async function readPushoverConfig(){const value=await readJsonFile(pushoverFile,null);if(!value||!pushoverKeyPattern.test(value.token||'')||!pushoverKeyPattern.test(value.user||''))return null;return {token:value.token,user:value.user};} +async function savePushoverConfig(input){const token=String(input?.token||'').trim();const user=String(input?.user||'').trim();if(!pushoverKeyPattern.test(token))throw new Error('Der Pushover Application API Token muss 30 alphanumerische Zeichen enthalten.');if(!pushoverKeyPattern.test(user))throw new Error('Der Pushover User/Group Key muss 30 alphanumerische Zeichen enthalten.');await writePrivateJson(pushoverFile,{token,user,updatedAt:new Date().toISOString()});return {configured:true,userMasked:maskKey(user)};} +async function sendPushover(config,{title,message}){ + const body=new URLSearchParams({token:config.token,user:config.user,title,message}); + const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.15'},body}); + let payload={}; try{payload=await response.json();}catch{} + if(!response.ok||payload.status!==1)throw new Error(Array.isArray(payload.errors)?payload.errors.join(' '):`Pushover-API Fehler (${response.status})`); + return payload; +} +function occursInReminderMonth(item,monthIndex){ + const interval=item.interval||'monthly'; if(interval==='monthly')return true; + const dueMonth=Number(item.dueMonth); if(!Number.isInteger(dueMonth)||dueMonth<1||dueMonth>12)return false; + const start=dueMonth-1; const step=interval==='quarterly'?3:interval==='semiannual'?6:12; + return monthIndex>=start&&(monthIndex-start)%step===0; +} +function reminderMessage(data,expense,now){ + const account=data.accounts.find(a=>a.id===expense.accountId)?.name||'Unbekanntes Konto'; + const amount=new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'}).format(Number(expense.amount||0)); + const month=new Intl.DateTimeFormat('de-DE',{month:'long'}).format(now); + return {title:`FixFin · ${expense.name}`,message:`Ausgabe im ${month}: ${amount}\nKonto: ${account}\nBitte prüfen, ob eine Umbuchung nötig ist.`}; +} +async function runPushoverReminderCheck(){ + const now=new Date(); if(now.getDate()!==1||now.getHours()x.pushoverReminder===true&&occursInReminderMonth(x,now.getMonth()))){ + const key=`${monthKey}:${expense.id}`; if(state.sent[key])continue; + try{await sendPushover(config,reminderMessage(data,expense,now));state.sent[key]=new Date().toISOString();changed=true;console.log(`[Pushover] Reminder gesendet: ${expense.name}`);}catch(error){console.error(`[Pushover] Reminder fehlgeschlagen (${expense.name}):`,error.message);} + } + const keepAfter=new Date(now.getFullYear()-2,now.getMonth(),1); for(const key of Object.keys(state.sent)){const ym=key.slice(0,7);const [y,m]=ym.split('-').map(Number);if(Number.isInteger(y)&&Number.isInteger(m)&&new Date(y,m-1,1)!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;} +async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];if(parsed.version!==7||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||(parsed.expenses||[]).some(x=>!Object.hasOwn(x,'pushoverReminder'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;} async function atomicWrite(input,makeBackup=true){const data=normalizeData(input);validateData(data);await fs.mkdir(dataDir,{recursive:true});const tmp=`${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`;const payload=JSON.stringify(data,null,2)+'\n';if(makeBackup){try{await fs.copyFile(dataFile,backupFile);}catch(error){if(error.code!=='ENOENT')throw error;}}await fs.writeFile(tmp,payload,{encoding:'utf8',mode:0o600});await fs.rename(tmp,dataFile);} -app.disable('x-powered-by');app.use(express.json({limit:'1mb'}));app.get('/api/health',(_req,res)=>res.json({ok:true}));app.get('/api/data',async(_req,res,next)=>{try{res.json(await readData());}catch(e){next(e);}});app.put('/api/data',async(req,res,next)=>{try{await atomicWrite(req.body);res.json(await readData());}catch(e){next(e);}});app.get('/api/export',async(_req,res,next)=>{try{const data=await readData();res.setHeader('Content-Disposition','attachment; filename="fixfin-backup.json"');res.type('application/json').send(JSON.stringify(data,null,2)+'\n');}catch(e){next(e);}}); -const dist=path.resolve(__dirname,'../dist');app.use(express.static(dist));app.use((_req,res)=>res.sendFile(path.join(dist,'index.html')));app.use((error,_req,res,_next)=>{console.error(error);res.status(400).json({error:error.message||'Unbekannter Fehler'});});await ensureDataFile();app.listen(port,'0.0.0.0',()=>{console.log(`FixFin läuft auf Port ${port}`);console.log(`Datendatei: ${dataFile}`);}); +app.disable('x-powered-by');app.use(express.json({limit:'1mb'}));app.get('/api/health',(_req,res)=>res.json({ok:true}));app.get('/api/data',async(_req,res,next)=>{try{res.json(await readData());}catch(e){next(e);}});app.put('/api/data',async(req,res,next)=>{try{await atomicWrite(req.body);res.json(await readData());}catch(e){next(e);}});app.get('/api/export',async(_req,res,next)=>{try{const data=await readData();res.setHeader('Content-Disposition','attachment; filename="fixfin-backup.json"');res.type('application/json').send(JSON.stringify(data,null,2)+'\n');}catch(e){next(e);}});app.get('/api/pushover/config',async(_req,res,next)=>{try{const config=await readPushoverConfig();res.json(config?{configured:true,userMasked:maskKey(config.user)}:{configured:false,userMasked:''});}catch(e){next(e);}});app.put('/api/pushover/config',async(req,res,next)=>{try{res.json(await savePushoverConfig(req.body));}catch(e){next(e);}});app.delete('/api/pushover/config',async(_req,res,next)=>{try{await fs.rm(pushoverFile,{force:true});res.json({configured:false,userMasked:''});}catch(e){next(e);}});app.post('/api/pushover/test',async(_req,res,next)=>{try{const config=await readPushoverConfig();if(!config)throw new Error('Pushover ist noch nicht konfiguriert.');await sendPushover(config,{title:'FixFin · Test',message:'Pushover ist erfolgreich mit FixFin verbunden.'});res.json({ok:true});}catch(e){next(e);}}); +const dist=path.resolve(__dirname,'../dist');app.use(express.static(dist));app.use((_req,res)=>res.sendFile(path.join(dist,'index.html')));app.use((error,_req,res,_next)=>{console.error(error);res.status(400).json({error:error.message||'Unbekannter Fehler'});});await ensureDataFile();app.listen(port,'0.0.0.0',()=>{console.log(`FixFin läuft auf Port ${port}`);console.log(`Datendatei: ${dataFile}`);console.log(`Pushover-Reminder: Prüfung am 1. des Monats ab ${String(reminderHour).padStart(2,'0')}:00 Uhr (${process.env.TZ||'System-Zeitzone'})`);});setTimeout(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),5000);setInterval(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),15*60*1000); diff --git a/src/main.jsx b/src/main.jsx index 0b85ed8..4bdd3ef 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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

{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}

Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.

{items.length===0?:<> -
{items.map(x=>)}
BezeichnungKategorieKontoIntervallMonatBetragØ / Monat
{x.name}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.accountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{isIncome?'+':'−'}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
+
{items.map(x=>)}
BezeichnungKategorieKontoIntervallMonatBetragØ / Monat
{x.name}{!isIncome&&x.pushoverReminder&&Reminder}{categoryNameLocal(x.categoryId)}{accountNameLocal(x.accountId)}{intervals[x.interval||'monthly'].label}{dueText(x)}{isIncome?'+':'−'}{formatMoney(x.amount)}{formatMoney(monthlyEquivalent(x))}
{items.map(x=>
-
{x.name}{categoryNameLocal(x.categoryId)}
{isIncome?'+':'−'}{formatMoney(x.amount)}
+
{x.name}{categoryNameLocal(x.categoryId)}{!isIncome&&x.pushoverReminder&&Reminder am 1.}
{isIncome?'+':'−'}{formatMoney(x.amount)}
Konto{accountNameLocal(x.accountId)}
Intervall{intervals[x.interval||'monthly'].label}
@@ -493,6 +493,22 @@ function ScenarioAdjustmentDialog({data,item,onClose,onSave}){ return
e.target===e.currentTarget&&onClose()}>
SZENARIO

{item?'Anpassung bearbeiten':'Anpassung hinzufügen'}

{error&&
{error}
}{type!=='transfer'?:<>}
; } +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

Pushover

Reminder für markierte Ausgaben werden am 1. ihres Fälligkeitsmonats serverseitig verschickt.

{status.loading?'Prüft …':status.configured?'Konfiguriert':'Nicht konfiguriert'}
+ {status.configured&&
User/Group Key{status.userMasked}
} + {error&&
{error}
}{message&&
{message}
} +
{status.configured&&<>}
+

Die Zugangsdaten werden separat serverseitig gespeichert und sind weder Teil des JSON-Exports noch der PWA-Daten.

+
; +} + 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
+

Daten sichern

Alle Daten liegen in einer JSON-Datei. Vor jedem Speichern wird serverseitig automatisch eine .bak-Datei angelegt.

JSON herunterladen

Aktueller Datenstand

Kurzübersicht über den Inhalt deiner FixFin-Datei.

{summary.map(([label,value])=>
{label}{Number(value).toLocaleString('de-DE')}
)}
@@ -516,7 +533,7 @@ function Empty({text}){return
{text}
} 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&&<>{interval!=='monthly'&&}} {(type==='incomes'||type==='expenses'||type==='transfers')&&<>{categoryId==='__new__'&&}} {(type==='incomes'||type==='expenses')&&} + {type==='expenses'&&} {type==='transfers'&&<>}
; } diff --git a/src/styles.css b/src/styles.css index 10be4c4..15199e2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } +}