This commit is contained in:
2026-08-18 08:36:21 +02:00
parent c28c98852b
commit cf05c7a733
5 changed files with 153 additions and 447 deletions
+7
View File
@@ -85,3 +85,10 @@ sudo FIXFIN_IMAGE=git.example.de/owner/fixfin:latest docker compose -f docker-co
## JSON-Migration ## JSON-Migration
Alte FixFin-v1-Dateien mit `balance` in Konten werden automatisch auf v2 migriert. Die alten Kontostände werden entfernt; Eingänge, Ausgänge und Transfers bleiben erhalten. Vor dem Umschreiben wird `finance.json.bak` angelegt. Alte FixFin-v1-Dateien mit `balance` in Konten werden automatisch auf v2 migriert. Die alten Kontostände werden entfernt; Eingänge, Ausgänge und Transfers bleiben erhalten. Vor dem Umschreiben wird `finance.json.bak` angelegt.
## Periodische Fixkosten und Jahresprognose (v1.2)
Eingänge, Ausgänge und Transfers unterstützen `monthly`, `quarterly`, `semiannual` und `yearly`.
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.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "fixfin", "name": "fixfin",
"version": "1.1.1", "version": "1.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+14 -148
View File
@@ -4,152 +4,18 @@ import path from 'node:path';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url); const __filename=fileURLToPath(import.meta.url); const __dirname=path.dirname(__filename); const app=express();
const __dirname = path.dirname(__filename); 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 app = express(); const validIntervals=new Set(['monthly','quarterly','semiannual','yearly']);
const port = Number(process.env.PORT || 3000); const emptyData=()=>({version:3,settings:{includePeriodicInBalance:true},accounts:[],incomes:[],expenses:[],transfers:[]});
const dataFile = process.env.DATA_FILE || '/app/data/finance.json'; const isFiniteNumber=value=>typeof value==='number'&&Number.isFinite(value);
const dataDir = path.dirname(dataFile);
const backupFile = `${dataFile}.bak`;
const emptyData = () => ({ 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 }; }
version: 2, function normalizeData(input){if(!input||typeof input!=='object')return input;return {version:3,settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false},accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts,incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeMovement):input.incomes,expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeMovement):input.expenses,transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeMovement):input.transfers};}
accounts: [], function validateMovement(item){if(!item.id||typeof item.id!=='string')throw new Error('Eintrag ohne gültige ID.');if(!item.name||typeof item.name!=='string')throw new Error('Eintrag ohne Namen.');if(!isFiniteNumber(item.amount)||item.amount<0)throw new Error(`Ungültiger Betrag bei '${item.name}'.`);if(!validIntervals.has(item.interval))throw new Error(`Ungültiges Intervall bei '${item.name}'.`);if(item.dueMonth!==null&&item.dueMonth!==undefined&&(!Number.isInteger(item.dueMonth)||item.dueMonth<1||item.dueMonth>12))throw new Error(`Ungültiger Monat bei '${item.name}'.`);}
incomes: [], function validateData(input){if(!input||typeof input!=='object')throw new Error('Ungültiges JSON.');for(const key of ['accounts','incomes','expenses','transfers'])if(!Array.isArray(input[key]))throw new Error(`Feld '${key}' muss ein Array sein.`);const accountIds=new Set();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);}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}'.`);}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.');}}
expenses: [], async function ensureDataFile(){await fs.mkdir(dataDir,{recursive:true});try{await fs.access(dataFile);}catch{await atomicWrite(emptyData(),false);}}
transfers: [], async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);if(parsed.version!==3||!parsed.settings||[...parsed.incomes||[],...parsed.expenses||[],...parsed.transfers||[]].some(x=>!x.interval)||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);}});
function isFiniteNumber(value) { 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}`);});
return typeof value === 'number' && Number.isFinite(value);
}
function normalizeData(input) {
if (!input || typeof input !== 'object') return input;
return {
version: 2,
accounts: Array.isArray(input.accounts)
? input.accounts.map(({ id, name }) => ({ id, name }))
: input.accounts,
incomes: input.incomes,
expenses: input.expenses,
transfers: input.transfers,
};
}
function validateData(input) {
if (!input || typeof input !== 'object') throw new Error('Ungültiges JSON.');
for (const key of ['accounts', 'incomes', 'expenses', 'transfers']) {
if (!Array.isArray(input[key])) throw new Error(`Feld '${key}' muss ein Array sein.`);
}
const accountIds = new Set();
for (const account of input.accounts) {
if (!account.id || typeof account.id !== 'string') throw new Error('Konto ohne gültige ID.');
if (!account.name || typeof account.name !== 'string') throw new Error('Konto ohne Namen.');
if (accountIds.has(account.id)) throw new Error('Doppelte Konto-ID.');
accountIds.add(account.id);
}
for (const collectionName of ['incomes', 'expenses']) {
for (const item of input[collectionName]) {
if (!item.id || typeof item.id !== 'string') throw new Error('Eintrag ohne gültige ID.');
if (!item.name || typeof item.name !== 'string') throw new Error('Eintrag ohne Namen.');
if (!isFiniteNumber(item.amount) || item.amount < 0) throw new Error(`Ungültiger Betrag bei '${item.name}'.`);
if (!accountIds.has(item.accountId)) throw new Error(`Unbekanntes Konto bei '${item.name}'.`);
}
}
for (const transfer of input.transfers) {
if (!transfer.id || typeof transfer.id !== 'string') throw new Error('Transfer ohne gültige ID.');
if (!transfer.name || typeof transfer.name !== 'string') throw new Error('Transfer ohne Namen.');
if (!isFiniteNumber(transfer.amount) || transfer.amount < 0) throw new Error(`Ungültiger Transferbetrag bei '${transfer.name}'.`);
if (!accountIds.has(transfer.fromAccountId) || !accountIds.has(transfer.toAccountId)) {
throw new Error(`Unbekanntes Konto beim Transfer '${transfer.name}'.`);
}
if (transfer.fromAccountId === transfer.toAccountId) throw new Error('Quell- und Zielkonto müssen verschieden sein.');
}
}
async function ensureDataFile() {
await fs.mkdir(dataDir, { recursive: true });
try {
await fs.access(dataFile);
} catch {
await atomicWrite(emptyData(), false);
}
}
async function readData() {
await ensureDataFile();
const raw = await fs.readFile(dataFile, 'utf8');
const parsed = JSON.parse(raw);
const data = normalizeData(parsed);
validateData(data);
// Migration von v1: alte account.balance-Werte werden bewusst entfernt.
const hadLegacyBalance = Array.isArray(parsed.accounts) && parsed.accounts.some(a => Object.hasOwn(a, 'balance'));
if (parsed.version !== 2 || hadLegacyBalance) {
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 (error) { next(error); }
});
app.put('/api/data', async (req, res, next) => {
try {
await atomicWrite(req.body);
res.json(await readData());
} catch (error) { next(error); }
});
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 (error) { next(error); }
});
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}`);
});
+99 -288
View File
@@ -3,12 +3,16 @@ import { createRoot } from 'react-dom/client';
import './styles.css'; import './styles.css';
const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }); const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
const emptyData = { version: 2, accounts: [], incomes: [], expenses: [], transfers: [] }; const months = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
const intervals = {
function formatMoney(value) { monthly: { label: 'Monatlich', divisor: 1, step: 1 },
return euro.format(Number(value || 0)); quarterly: { label: 'Vierteljährlich', divisor: 3, step: 3 },
} semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 },
yearly: { label: 'Jährlich', divisor: 12, step: 12 },
};
const emptyData = { version: 3, settings: { includePeriodicInBalance: true }, accounts: [], incomes: [], expenses: [], transfers: [] };
function formatMoney(value) { return euro.format(Number(value || 0)); }
function parseAmount(value) { function parseAmount(value) {
if (typeof value === 'number') return value; if (typeof value === 'number') return value;
const raw = String(value).trim(); const raw = String(value).trim();
@@ -17,331 +21,138 @@ function parseAmount(value) {
const number = Number(normalized); const number = Number(normalized);
return Number.isFinite(number) ? number : Number.NaN; return Number.isFinite(number) ? number : Number.NaN;
} }
function uid() { function uid() {
// crypto.randomUUID() ist in Browsern nur in sicheren Kontexten (HTTPS/localhost) if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
// garantiert verfügbar. FixFin soll aber auch direkt über http://LAN-IP laufen.
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
if (globalThis.crypto?.getRandomValues) { if (globalThis.crypto?.getRandomValues) {
const bytes = new Uint8Array(16); const bytes = new Uint8Array(16); globalThis.crypto.getRandomValues(bytes);
globalThis.crypto.getRandomValues(bytes); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80;
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = [...bytes].map(b => b.toString(16).padStart(2, '0')).join(''); const hex = [...bytes].map(b => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`; return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
} }
// Letzter Fallback für sehr alte Browser. Für lokale IDs reicht die Kombination.
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`; return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
} }
function monthlyEquivalent(item) { return Number(item.amount || 0) / (intervals[item.interval]?.divisor || 1); }
function isPeriodic(item) { return (item.interval || 'monthly') !== 'monthly'; }
function balanceAmount(item, includePeriodic) {
if (!isPeriodic(item)) return Number(item.amount || 0);
return includePeriodic ? monthlyEquivalent(item) : 0;
}
function occursInMonth(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 = intervals[interval]?.step || 12;
return monthIndex >= start && (monthIndex - start) % step === 0;
}
function calc(data) { function calc(data) {
const accountStats = new Map(data.accounts.map(a => [a.id, { const includePeriodic = data.settings?.includePeriodicInBalance !== false;
account: a, const accountStats = new Map(data.accounts.map(a => [a.id, { account: a, income: 0, expense: 0, transferIn: 0, transferOut: 0, periodicReserve: 0, balance: 0 }]));
income: 0,
expense: 0,
transferIn: 0,
transferOut: 0,
balance: 0,
}]));
for (const item of data.incomes) { for (const item of data.incomes) {
const stat = accountStats.get(item.accountId); const stat = accountStats.get(item.accountId); if (!stat) continue;
if (stat) stat.income += item.amount; const value = balanceAmount(item, includePeriodic); stat.income += value;
if (isPeriodic(item)) stat.periodicReserve += monthlyEquivalent(item);
} }
for (const item of data.expenses) { for (const item of data.expenses) {
const stat = accountStats.get(item.accountId); const stat = accountStats.get(item.accountId); if (!stat) continue;
if (stat) stat.expense += item.amount; const value = balanceAmount(item, includePeriodic); stat.expense += value;
if (isPeriodic(item)) stat.periodicReserve += monthlyEquivalent(item);
} }
for (const item of data.transfers) { for (const item of data.transfers) {
const from = accountStats.get(item.fromAccountId); const value = balanceAmount(item, includePeriodic);
const to = accountStats.get(item.toAccountId); const from = accountStats.get(item.fromAccountId); const to = accountStats.get(item.toAccountId);
if (from) from.transferOut += item.amount; if (from) from.transferOut += value; if (to) to.transferIn += value;
if (to) to.transferIn += item.amount; }
for (const stat of accountStats.values()) stat.balance = stat.income - stat.expense + stat.transferIn - stat.transferOut;
const income = data.incomes.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0);
const expense = data.expenses.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0);
const transferVolume = data.transfers.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0);
const periodicExpenseReserve = data.expenses.filter(isPeriodic).reduce((s,x) => s + monthlyEquivalent(x), 0);
const periodicIncomeAverage = data.incomes.filter(isPeriodic).reduce((s,x) => s + monthlyEquivalent(x), 0);
return { accounts: [...accountStats.values()], income, expense, transferVolume, periodicExpenseReserve, periodicIncomeAverage, balance: income - expense, includePeriodic };
} }
for (const stat of accountStats.values()) { function annualForecast(data) {
stat.balance = stat.income - stat.expense + stat.transferIn - stat.transferOut; const rows = months.map((name, index) => ({ name, index, income: 0, expense: 0, transferIn: 0, transferOut: 0, balance: 0, items: [] }));
} for (const item of data.incomes) for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].income += item.amount; rows[m].items.push({ ...item, kind:'income' }); }
for (const item of data.expenses) for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].expense += item.amount; rows[m].items.push({ ...item, kind:'expense' }); }
const income = data.incomes.reduce((s, x) => s + x.amount, 0); for (const item of data.transfers) for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].transferIn += item.amount; rows[m].transferOut += item.amount; rows[m].items.push({ ...item, kind:'transfer' }); }
const expense = data.expenses.reduce((s, x) => s + x.amount, 0); rows.forEach(r => { r.balance = r.income - r.expense; });
const transferVolume = data.transfers.reduce((s, x) => s + x.amount, 0); const unassigned = [
const balance = income - expense; ...data.incomes.map(x => ({...x, kind:'income'})),
...data.expenses.map(x => ({...x, kind:'expense'})),
return { ...data.transfers.map(x => ({...x, kind:'transfer'})),
accounts: [...accountStats.values()], ].filter(x => isPeriodic(x) && !(Number.isInteger(Number(x.dueMonth)) && Number(x.dueMonth) >= 1 && Number(x.dueMonth) <= 12));
income, return { rows, unassigned, yearIncome: rows.reduce((s,r)=>s+r.income,0), yearExpense: rows.reduce((s,r)=>s+r.expense,0) };
expense,
transferVolume,
balance,
};
} }
function Icon({ name }) { function Icon({ name }) {
const paths = { const paths = {
dashboard:<><rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/></>, dashboard:<><rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/></>,
wallet:<><path d="M4 7h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12"/><path d="M16 12h4"/></>, wallet:<><path d="M4 7h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12"/><path d="M16 12h4"/></>,
plus: <><path d="M12 5v14M5 12h14"/></>, plus:<><path d="M12 5v14M5 12h14"/></>, minus:<><path d="M5 12h14"/></>,
minus: <><path d="M5 12h14"/></>,
transfer:<><path d="m17 3 4 4-4 4"/><path d="M3 7h18"/><path d="m7 21-4-4 4-4"/><path d="M21 17H3"/></>, transfer:<><path d="m17 3 4 4-4 4"/><path d="M3 7h18"/><path d="m7 21-4-4 4-4"/><path d="M21 17H3"/></>,
calendar:<><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 10h18"/></>,
data:<><path d="M12 3c5 0 9 1.3 9 3s-4 3-9 3-9-1.3-9-3 4-3 9-3Z"/><path d="M3 6v6c0 1.7 4 3 9 3s9-1.3 9-3V6"/><path d="M3 12v6c0 1.7 4 3 9 3s9-1.3 9-3v-6"/></>, data:<><path d="M12 3c5 0 9 1.3 9 3s-4 3-9 3-9-1.3-9-3 4-3 9-3Z"/><path d="M3 6v6c0 1.7 4 3 9 3s9-1.3 9-3V6"/><path d="M3 12v6c0 1.7 4 3 9 3s9-1.3 9-3v-6"/></>,
}; };
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>; return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>;
} }
function App() { function App() {
const [data, setData] = useState(emptyData); const [data,setData]=useState(emptyData), [tab,setTab]=useState('dashboard'), [loading,setLoading]=useState(true), [saving,setSaving]=useState(false), [error,setError]=useState(''), [dialog,setDialog]=useState(null);
const [tab, setTab] = useState('dashboard'); const totals=useMemo(()=>calc(data),[data]); const forecast=useMemo(()=>annualForecast(data),[data]);
const [loading, setLoading] = useState(true); useEffect(()=>{ fetch('/api/data').then(async r=>{if(!r.ok)throw new Error((await r.json()).error||'Daten konnten nicht geladen werden.');return r.json();}).then(setData).catch(e=>setError(e.message)).finally(()=>setLoading(false)); },[]);
const [saving, setSaving] = useState(false); async function persist(next){setSaving(true);setError('');try{const r=await fetch('/api/data',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(next)});const result=await r.json();if(!r.ok)throw new Error(result.error||'Speichern fehlgeschlagen.');setData(result);}catch(e){setError(e.message);throw e;}finally{setSaving(false);}}
const [error, setError] = useState(''); function removeAccount(id){const used=data.incomes.some(x=>x.accountId===id)||data.expenses.some(x=>x.accountId===id)||data.transfers.some(x=>x.fromAccountId===id||x.toAccountId===id);if(used)return setError('Das Konto wird noch verwendet. Einträge oder Transfers bitte zuerst löschen.');if(confirm('Konto wirklich löschen?'))persist({...data,accounts:data.accounts.filter(x=>x.id!==id)});}
const [dialog, setDialog] = useState(null); function removeItem(type,id){if(confirm('Eintrag wirklich löschen?'))persist({...data,[type]:data[type].filter(x=>x.id!==id)});}
async function importFile(file){try{await persist(JSON.parse(await file.text()));setTab('dashboard');}catch(e){setError(`Import fehlgeschlagen: ${e.message}`);}}
const totals = useMemo(() => calc(data), [data]); async function togglePeriodic(){await persist({...data,settings:{...data.settings,includePeriodicInBalance:!(data.settings?.includePeriodicInBalance!==false)}});}
useEffect(() => {
fetch('/api/data')
.then(async r => {
if (!r.ok) throw new Error((await r.json()).error || 'Daten konnten nicht geladen werden.');
return r.json();
})
.then(setData)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, []);
async function persist(next) {
setSaving(true);
setError('');
try {
const r = await fetch('/api/data', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(next),
});
const result = await r.json();
if (!r.ok) throw new Error(result.error || 'Speichern fehlgeschlagen.');
setData(result);
} catch (e) {
setError(e.message);
throw e;
} finally {
setSaving(false);
}
}
function removeAccount(id) {
const used = data.incomes.some(x => x.accountId === id) ||
data.expenses.some(x => x.accountId === id) ||
data.transfers.some(x => x.fromAccountId === id || x.toAccountId === id);
if (used) {
setError('Das Konto wird noch von Einträgen oder Transfers verwendet. Diese bitte zuerst löschen.');
return;
}
if (confirm('Konto wirklich löschen?')) persist({ ...data, accounts: data.accounts.filter(x => x.id !== id) });
}
function removeItem(type, id) {
if (!confirm('Eintrag wirklich löschen?')) return;
persist({ ...data, [type]: data[type].filter(x => x.id !== id) });
}
async function importFile(file) {
try {
const text = await file.text();
const parsed = JSON.parse(text);
await persist(parsed);
setTab('dashboard');
} catch (e) {
setError(`Import fehlgeschlagen: ${e.message}`);
}
}
if(loading)return <div className="loading">FixFin wird geladen </div>; if(loading)return <div className="loading">FixFin wird geladen </div>;
const nav = [ const nav=[['dashboard','dashboard','Übersicht'],['accounts','wallet','Konten'],['incomes','plus','Eingänge'],['expenses','minus','Ausgänge'],['transfers','transfer','Transfers'],['year','calendar','Jahr'],['data','data','Daten']];
['dashboard', 'dashboard', 'Übersicht'], return <div className="app-shell">
['accounts', 'wallet', 'Konten'], <aside className="sidebar"><div className="brand"><div className="brand-mark">F</div><div><strong>FixFin</strong><span>Fixkostenplanung</span></div></div><nav>{nav.map(([id,icon,label])=><button key={id} className={tab===id?'active':''} onClick={()=>setTab(id)}><Icon name={icon}/><span>{label}</span></button>)}</nav><div className="save-state">{saving?'Speichert …':'Automatisch gespeichert'}</div></aside>
['incomes', 'plus', 'Eingänge'], <main><header className="topbar"><div><h1>{nav.find(x=>x[0]===tab)?.[2]}</h1><p>Fixe Einnahmen, Ausgaben und Transfers ohne Buchungsverlauf.</p></div>{!['dashboard','year','data'].includes(tab)&&<button className="primary" onClick={()=>setDialog({type:tab})}>+ Neu</button>}</header>
['expenses', 'minus', 'Ausgänge'],
['transfers', 'transfer', 'Transfers'],
['data', 'data', 'Daten'],
];
return (
<div className="app-shell">
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">F</div>
<div><strong>FixFin</strong><span>Monatsplanung</span></div>
</div>
<nav>
{nav.map(([id, icon, label]) => (
<button key={id} className={tab === id ? 'active' : ''} onClick={() => setTab(id)}>
<Icon name={icon}/><span>{label}</span>
</button>
))}
</nav>
<div className="save-state">{saving ? 'Speichert …' : 'Automatisch gespeichert'}</div>
</aside>
<main>
<header className="topbar">
<div>
<h1>{nav.find(x => x[0] === tab)?.[2]}</h1>
<p>Fixe monatliche Planung ohne Buchungsverlauf.</p>
</div>
{tab !== 'dashboard' && tab !== 'data' && (
<button className="primary" onClick={() => setDialog({ type: tab })}>+ Neu</button>
)}
</header>
{error&&<div className="alert"><span>{error}</span><button onClick={()=>setError('')}>×</button></div>} {error&&<div className="alert"><span>{error}</span><button onClick={()=>setError('')}>×</button></div>}
{tab==='dashboard'&&<Dashboard totals={totals} onToggle={togglePeriodic}/>} {tab==='accounts'&&<Accounts data={data} onEdit={x=>setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>} {tab==='incomes'&&<Entries type="incomes" items={data.incomes} accounts={data.accounts} onEdit={x=>setDialog({type:'incomes',item:x})} onDelete={removeItem}/>} {tab==='expenses'&&<Entries type="expenses" items={data.expenses} accounts={data.accounts} onEdit={x=>setDialog({type:'expenses',item:x})} onDelete={removeItem}/>} {tab==='transfers'&&<Transfers data={data} onEdit={x=>setDialog({type:'transfers',item:x})} onDelete={removeItem}/>} {tab==='year'&&<YearPage forecast={forecast}/>} {tab==='data'&&<DataPage data={data} importFile={importFile}/>}
{tab === 'dashboard' && <Dashboard totals={totals} />}
{tab === 'accounts' && <Accounts data={data} onEdit={x => setDialog({ type: 'accounts', item: x })} onDelete={removeAccount} onAdd={() => setDialog({ type: 'accounts' })}/>}
{tab === 'incomes' && <Entries title="Fixe Eingänge" type="incomes" items={data.incomes} accounts={data.accounts} onEdit={x => setDialog({ type: 'incomes', item: x })} onDelete={removeItem}/>}
{tab === 'expenses' && <Entries title="Fixe Ausgänge" type="expenses" items={data.expenses} accounts={data.accounts} onEdit={x => setDialog({ type: 'expenses', item: x })} onDelete={removeItem}/>}
{tab === 'transfers' && <Transfers data={data} onEdit={x => setDialog({ type: 'transfers', item: x })} onDelete={removeItem}/>}
{tab === 'data' && <DataPage data={data} importFile={importFile}/>}
</main> </main>
<nav className="mobile-nav">{nav.map(([id,icon,label])=><button key={id} className={tab===id?'active':''} onClick={()=>setTab(id)}><Icon name={icon}/><span>{label}</span></button>)}</nav>
<nav className="mobile-nav">
{nav.map(([id, icon, label]) => (
<button key={id} className={tab === id ? 'active' : ''} onClick={() => setTab(id)}>
<Icon name={icon}/><span>{label}</span>
</button>
))}
</nav>
{dialog&&<EditorDialog dialog={dialog} data={data} onClose={()=>setDialog(null)} onSave={async next=>{await persist(next);setDialog(null);}}/>} {dialog&&<EditorDialog dialog={dialog} data={data} onClose={()=>setDialog(null)} onSave={async next=>{await persist(next);setDialog(null);}}/>}
</div> </div>;
);
} }
function Dashboard({ totals }) { function Dashboard({totals,onToggle}){
return <div className="content-stack"> return <div className="content-stack">
<section className="summary-grid"> <section className="toggle-panel"><div><strong>Periodische Kosten in Monats-Saldo einrechnen</strong><span>Jährliche, halbjährliche und vierteljährliche Beträge werden auf einen Monatswert heruntergerechnet.</span></div><button className={`switch ${totals.includePeriodic?'on':''}`} onClick={onToggle} role="switch" aria-checked={totals.includePeriodic}><span/></button></section>
<Metric label="Fixe Eingänge / Monat" value={totals.income} positive /> <section className="summary-grid"><Metric label="Eingänge / Monat" value={totals.income} positive/><Metric label="Ausgänge / Monat" value={totals.expense} negative/><Metric label="Periodische Rücklage / Monat" value={totals.periodicExpenseReserve}/><Metric label="Monatlicher Gesamtsaldo" value={totals.balance} positive={totals.balance>=0} negative={totals.balance<0} featured/></section>
<Metric label="Fixe Ausgänge / Monat" value={totals.expense} negative /> {!totals.includePeriodic&&totals.periodicExpenseReserve>0&&<div className="info-note">Periodische Ausgaben von rechnerisch <strong>{formatMoney(totals.periodicExpenseReserve)} / Monat</strong> sind derzeit nicht im Saldo enthalten.</div>}
<Metric label="Transfers / Monat" value={totals.transferVolume} /> <section className="panel"><div className="section-head"><div><h2>Saldo je Konto</h2><p>Monatliche Fixwerte plus optional heruntergerechnete periodische Werte.</p></div></div>{totals.accounts.length===0?<Empty text="Lege zuerst ein Konto an."/>:<div className="account-summary-grid">{totals.accounts.map(s=><article className="account-summary" key={s.account.id}><div className="account-summary-top"><h3>{s.account.name}</h3><span className={s.balance>=0?'pill good':'pill bad'}>{s.balance>=0?'+':''}{formatMoney(s.balance)}</span></div><div className={`big-money ${s.balance>=0?'good-text':'bad-text'}`}>{formatMoney(s.balance)}</div><div className="muted">monatlicher Fix-Saldo</div><div className="mini-grid"><div><span>Eingänge</span><strong className="good-text">+{formatMoney(s.income)}</strong></div><div><span>Ausgänge</span><strong className="bad-text">{formatMoney(s.expense)}</strong></div><div><span>Transfers rein</span><strong className="good-text">+{formatMoney(s.transferIn)}</strong></div><div><span>Transfers raus</span><strong className="bad-text">{formatMoney(s.transferOut)}</strong></div></div></article>)}</div>}</section>
<Metric label="Monatlicher Gesamtsaldo" value={totals.balance} positive={totals.balance >= 0} negative={totals.balance < 0} featured /> <section className="panel total-panel"><div><span className="eyebrow">GESAMT</span><h2>Monatlich nach Fixkosten übrig</h2><p>Interne Transfers verändern den Gesamtsaldo nicht.</p></div><div className={`grand-total ${totals.balance>=0?'good-text':'bad-text'}`}>{formatMoney(totals.balance)}</div></section>
</section> </div>;
<section className="panel">
<div className="section-head"><div><h2>Saldo je Konto</h2><p>Nur fixe Monatswerte. Es gibt keinen Kontostand und keinen Buchungsverlauf.</p></div></div>
{totals.accounts.length === 0 ? <Empty text="Lege zuerst ein Konto an."/> : (
<div className="account-summary-grid">
{totals.accounts.map(s => <article className="account-summary" key={s.account.id}>
<div className="account-summary-top"><h3>{s.account.name}</h3><span className={s.balance >= 0 ? 'pill good' : 'pill bad'}>{s.balance >= 0 ? '+' : ''}{formatMoney(s.balance)}</span></div>
<div className={`big-money ${s.balance >= 0 ? 'good-text' : 'bad-text'}`}>{formatMoney(s.balance)}</div>
<div className="muted">monatlich nach allen Fixbewegungen</div>
<div className="mini-grid">
<div><span>Eingänge</span><strong className="good-text">+{formatMoney(s.income)}</strong></div>
<div><span>Ausgänge</span><strong className="bad-text">{formatMoney(s.expense)}</strong></div>
<div><span>Transfers rein</span><strong className="good-text">+{formatMoney(s.transferIn)}</strong></div>
<div><span>Transfers raus</span><strong className="bad-text">{formatMoney(s.transferOut)}</strong></div>
</div>
</article>)}
</div>
)}
</section>
<section className="panel total-panel">
<div><span className="eyebrow">GESAMT</span><h2>Monatlich nach Fixkosten übrig</h2><p>Interne Transfers verändern den Gesamtsaldo nicht.</p></div>
<div className={`grand-total ${totals.balance >= 0 ? 'good-text' : 'bad-text'}`}>{formatMoney(totals.balance)}</div>
</section>
</div>
} }
function Metric({label,value,positive,negative,featured}){return <div className={`metric ${featured?'featured':''}`}><span>{label}</span><strong className={positive?'good-text':negative?'bad-text':''}>{formatMoney(value)}</strong></div>}
function Accounts({data,onEdit,onDelete,onAdd}){return <section className="panel"><div className="section-head"><div><h2>Konten</h2><p>Konten dienen nur zur Zuordnung. Es gibt keine Kontostände.</p></div><button className="secondary desktop-only" onClick={onAdd}>+ Konto</button></div>{data.accounts.length===0?<Empty text="Noch keine Konten vorhanden."/>:<div className="table-wrap"><table><thead><tr><th>Konto</th><th></th></tr></thead><tbody>{data.accounts.map(a=><tr key={a.id}><td><strong>{a.name}</strong></td><td className="actions"><button onClick={()=>onEdit(a)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(a.id)}>Löschen</button></td></tr>)}</tbody></table></div>}</section>}
function Entries({type,items,accounts,onEdit,onDelete}){const accountName=id=>accounts.find(a=>a.id===id)?.name||'Unbekannt';const isIncome=type==='incomes';return <section className="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"><table><thead><tr><th>Bezeichnung</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>{accountName(x.accountId)}</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 ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':''}{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(type,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 Metric({ label, value, positive, negative, featured }) { function YearPage({forecast}){
return <div className={`metric ${featured ? 'featured' : ''}`}><span>{label}</span><strong className={positive ? 'good-text' : negative ? 'bad-text' : ''}>{formatMoney(value)}</strong></div> 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>
} <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>
{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>}
function Accounts({ data, onEdit, onDelete, onAdd }) { </div>;
return <section className="panel">
<div className="section-head"><div><h2>Konten</h2><p>Konten dienen nur zur Zuordnung deiner monatlichen Fixbewegungen. Es wird kein Kontostand gespeichert.</p></div><button className="secondary desktop-only" onClick={onAdd}>+ Konto</button></div>
{data.accounts.length === 0 ? <Empty text="Noch keine Konten vorhanden."/> : <div className="table-wrap"><table><thead><tr><th>Konto</th><th></th></tr></thead><tbody>{data.accounts.map(a => <tr key={a.id}><td><strong>{a.name}</strong></td><td className="actions"><button onClick={() => onEdit(a)}>Bearbeiten</button><button className="danger" onClick={() => onDelete(a.id)}>Löschen</button></td></tr>)}</tbody></table></div>}
</section>
}
function Entries({ type, items, accounts, onEdit, onDelete }) {
const accountName = id => accounts.find(a => a.id === id)?.name || 'Unbekannt';
const isIncome = type === 'incomes';
return <section className="panel">
<div className="section-head"><div><h2>{isIncome ? 'Fixe Eingänge' : 'Fixe Ausgänge'}</h2><p>Alle Beträge gelten monatlich und haben bewusst kein Datum.</p></div></div>
{items.length === 0 ? <Empty text={`Noch keine ${isIncome ? 'Eingänge' : 'Ausgänge'} vorhanden.`}/> : <div className="table-wrap"><table><thead><tr><th>Bezeichnung</th><th>Konto</th><th className="number">Monatlich</th><th></th></tr></thead><tbody>{items.map(x => <tr key={x.id}><td><strong>{x.name}</strong></td><td>{accountName(x.accountId)}</td><td className={`number ${isIncome ? 'good-text' : 'bad-text'}`}>{isIncome ? '+' : ''}{formatMoney(x.amount)}</td><td className="actions"><button onClick={() => onEdit(x)}>Bearbeiten</button><button className="danger" onClick={() => onDelete(type, 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 className="number">Monatlich</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 className="number">{formatMoney(x.amount)}</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 DataPage({ data, importFile }) {
return <div className="content-stack">
<section className="panel">
<div className="section-head"><div><h2>JSON sichern</h2><p>Alle Daten liegen in einer einzigen JSON-Datei. Vor jedem Speichern wird serverseitig zusätzlich eine <code>.bak</code>-Datei angelegt.</p></div></div>
<div className="button-row"><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"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>{data.accounts.length} Konten · {data.incomes.length} Eingänge · {data.expenses.length} Ausgänge · {data.transfers.length} Transfers</p></div></div><pre className="json-preview">{JSON.stringify(data, null, 2)}</pre></section>
</div>
}
function Empty({ text }) {
return <div className="empty">{text}</div>
} }
function DataPage({data,importFile}){return <div className="content-stack"><section className="panel"><div className="section-head"><div><h2>JSON sichern</h2><p>Alle Daten liegen in einer einzigen JSON-Datei. Vor jedem Speichern wird serverseitig eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row"><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"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>{data.accounts.length} Konten · {data.incomes.length} Eingänge · {data.expenses.length} Ausgänge · {data.transfers.length} Transfers</p></div></div><pre className="json-preview">{JSON.stringify(data,null,2)}</pre></section></div>}
function Empty({text}){return <div className="empty">{text}</div>}
function EditorDialog({dialog,data,onClose,onSave}){ function EditorDialog({dialog,data,onClose,onSave}){
const type = dialog.type; const type=dialog.type,item=dialog.item; 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):''),[localError,setLocalError]=useState('');
const item = dialog.item;
const [name, setName] = useState(item?.name || '');
const [amount, setAmount] = useState(item?.amount ?? '');
const [accountId, setAccountId] = useState(item?.accountId || data.accounts[0]?.id || '');
const [fromAccountId, setFromAccountId] = useState(item?.fromAccountId || data.accounts[0]?.id || '');
const [toAccountId, setToAccountId] = useState(item?.toAccountId || data.accounts[1]?.id || data.accounts[0]?.id || '');
const [localError, setLocalError] = useState('');
const labels={accounts:'Konto',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'}; const labels={accounts:'Konto',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'};
async function submit(e){e.preventDefault();setLocalError('');const numeric=parseAmount(amount);if(!name.trim())return setLocalError('Bitte eine Bezeichnung eingeben.');if(type!=='accounts'&&(numeric<0||!Number.isFinite(numeric)))return setLocalError('Bitte einen gültigen Betrag eingeben.');if(type!=='accounts'&&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.');let next=structuredClone(data);const id=item?.id||uid();let value;if(type==='accounts')value={id,name:name.trim()};else {const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null)};if(type==='incomes'||type==='expenses')value={...common,accountId};if(type==='transfers')value={...common,fromAccountId,toAccountId};}next[type]=item?next[type].map(x=>x.id===item.id?value:x):[...next[type],value];try{await onSave(next);}catch(e){setLocalError(e.message);}}
async function submit(e) { 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">{item?'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':'z. B. Kfz-Versicherung'}/></label>
e.preventDefault(); {type!=='accounts'&&<><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>}</>}
setLocalError(''); {(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==='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>;
const numeric = parseAmount(amount);
if (!name.trim()) return setLocalError('Bitte eine Bezeichnung eingeben.');
if (type !== 'accounts' && (numeric < 0 || !Number.isFinite(numeric))) return setLocalError('Bitte einen gültigen Betrag eingeben.');
if (type !== 'accounts' && 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.');
let next = structuredClone(data);
const id = item?.id || uid();
let value;
if (type === 'accounts') value = { id, name: name.trim() };
if (type === 'incomes' || type === 'expenses') value = { id, name: name.trim(), amount: numeric, accountId };
if (type === 'transfers') value = { id, name: name.trim(), amount: numeric, fromAccountId, toAccountId };
next[type] = item ? next[type].map(x => x.id === item.id ? value : x) : [...next[type], value];
try { await onSave(next); } catch (e) { setLocalError(e.message); }
} }
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">{item ? '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' : 'z. B. Gehalt'} /></label>
{type !== 'accounts' && <label><span>Betrag pro 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 === '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 === '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>
}
createRoot(document.getElementById('root')).render(<App/>); createRoot(document.getElementById('root')).render(<App/>);
+22
View File
@@ -126,3 +126,25 @@ code { color:#b8c3ff; }
.summary-grid { grid-template-columns:1fr; } .summary-grid { grid-template-columns:1fr; }
.topbar .primary { padding:9px 11px; } .topbar .primary { padding:9px 11px; }
} }
/* v1.2: periodische Fixkosten + Jahresprognose */
.toggle-panel { display:flex; align-items:center; justify-content:space-between; gap:20px; padding:16px 18px; border:1px solid var(--line); background:#101722; border-radius:15px; }
.toggle-panel strong { display:block; font-size:14px; }
.toggle-panel span:not(.switch span) { display:block; color:var(--muted); font-size:12px; margin-top:4px; }
.switch { width:48px; height:27px; border-radius:999px; border:1px solid #354257; padding:2px; background:#202937; flex:0 0 auto; transition:.2s; }
.switch span { display:block; width:21px; height:21px; border-radius:50%; background:#8793a4; transition:.2s; }
.switch.on { background:var(--accent); border-color:var(--accent); }
.switch.on span { transform:translateX(20px); background:#101522; }
.info-note { padding:12px 15px; border-radius:12px; border:1px solid #65562a; background:#2b2616; color:#e9d98b; font-size:13px; }
.interval-badge { display:inline-block; padding:4px 8px; border-radius:999px; background:#1c2635; border:1px solid #304057; color:#b8c5d7; font-size:10px; font-weight:800; white-space:nowrap; }
.field-help { color:var(--muted); font-size:11px; margin-top:-2px; }
.year-summary { grid-template-columns:repeat(4,minmax(0,1fr)); }
.year-table { min-width:850px; }
.due-items { display:flex; flex-wrap:wrap; gap:5px; }
.due-chip { display:inline-flex; padding:4px 7px; border-radius:7px; font-size:10px; border:1px solid #344057; background:#192231; color:#c4cede; white-space:nowrap; }
.due-chip.income { border-color:#245947; color:#7de0b5; background:#112c24; }
.due-chip.expense { border-color:#67333b; color:#ff9aa2; background:#321a20; }
.due-chip.transfer { border-color:#414d7d; color:#adb9ff; background:#1c2340; }
.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:430px) { .year-summary { grid-template-columns:1fr; } }