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
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",
"version": "1.1.1",
"version": "1.2.0",
"private": true,
"type": "module",
"scripts": {
+14 -148
View File
@@ -4,152 +4,18 @@ import path from 'node:path';
import crypto from 'node:crypto';
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 __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 validIntervals=new Set(['monthly','quarterly','semiannual','yearly']);
const emptyData=()=>({version:3,settings:{includePeriodicInBalance:true},accounts:[],incomes:[],expenses:[],transfers:[]});
const isFiniteNumber=value=>typeof value==='number'&&Number.isFinite(value);
const emptyData = () => ({
version: 2,
accounts: [],
incomes: [],
expenses: [],
transfers: [],
});
function isFiniteNumber(value) {
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}`);
});
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 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};}
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}'.`);}
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.');}}
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);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);}});
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}`);});
+108 -297
View File
@@ -3,12 +3,16 @@ import { createRoot } from 'react-dom/client';
import './styles.css';
const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
const emptyData = { version: 2, accounts: [], incomes: [], expenses: [], transfers: [] };
function formatMoney(value) {
return euro.format(Number(value || 0));
}
const months = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
const intervals = {
monthly: { label: 'Monatlich', divisor: 1, step: 1 },
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) {
if (typeof value === 'number') return value;
const raw = String(value).trim();
@@ -17,331 +21,138 @@ function parseAmount(value) {
const number = Number(normalized);
return Number.isFinite(number) ? number : Number.NaN;
}
function uid() {
// crypto.randomUUID() ist in Browsern nur in sicheren Kontexten (HTTPS/localhost)
// garantiert verfügbar. FixFin soll aber auch direkt über http://LAN-IP laufen.
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
if (globalThis.crypto?.getRandomValues) {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const bytes = new Uint8Array(16); globalThis.crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80;
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)}`;
}
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) {
const accountStats = new Map(data.accounts.map(a => [a.id, {
account: a,
income: 0,
expense: 0,
transferIn: 0,
transferOut: 0,
balance: 0,
}]));
const includePeriodic = data.settings?.includePeriodicInBalance !== false;
const accountStats = new Map(data.accounts.map(a => [a.id, { account: a, income: 0, expense: 0, transferIn: 0, transferOut: 0, periodicReserve: 0, balance: 0 }]));
for (const item of data.incomes) {
const stat = accountStats.get(item.accountId);
if (stat) stat.income += item.amount;
const stat = accountStats.get(item.accountId); if (!stat) continue;
const value = balanceAmount(item, includePeriodic); stat.income += value;
if (isPeriodic(item)) stat.periodicReserve += monthlyEquivalent(item);
}
for (const item of data.expenses) {
const stat = accountStats.get(item.accountId);
if (stat) stat.expense += item.amount;
const stat = accountStats.get(item.accountId); if (!stat) continue;
const value = balanceAmount(item, includePeriodic); stat.expense += value;
if (isPeriodic(item)) stat.periodicReserve += monthlyEquivalent(item);
}
for (const item of data.transfers) {
const from = accountStats.get(item.fromAccountId);
const to = accountStats.get(item.toAccountId);
if (from) from.transferOut += item.amount;
if (to) to.transferIn += item.amount;
const value = balanceAmount(item, includePeriodic);
const from = accountStats.get(item.fromAccountId); const to = accountStats.get(item.toAccountId);
if (from) from.transferOut += value; if (to) to.transferIn += value;
}
for (const stat of accountStats.values()) stat.balance = stat.income - stat.expense + stat.transferIn - stat.transferOut;
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 };
}
const income = data.incomes.reduce((s, x) => s + x.amount, 0);
const expense = data.expenses.reduce((s, x) => s + x.amount, 0);
const transferVolume = data.transfers.reduce((s, x) => s + x.amount, 0);
const balance = income - expense;
return {
accounts: [...accountStats.values()],
income,
expense,
transferVolume,
balance,
};
function annualForecast(data) {
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' }); }
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' }); }
rows.forEach(r => { r.balance = r.income - r.expense; });
const unassigned = [
...data.incomes.map(x => ({...x, kind:'income'})),
...data.expenses.map(x => ({...x, kind:'expense'})),
...data.transfers.map(x => ({...x, kind:'transfer'})),
].filter(x => isPeriodic(x) && !(Number.isInteger(Number(x.dueMonth)) && Number(x.dueMonth) >= 1 && Number(x.dueMonth) <= 12));
return { rows, unassigned, yearIncome: rows.reduce((s,r)=>s+r.income,0), yearExpense: rows.reduce((s,r)=>s+r.expense,0) };
}
function Icon({ name }) {
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"/></>,
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"/></>,
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"/></>,
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"/></>,
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"/></>,
plus:<><path d="M12 5v14M5 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"/></>,
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"/></>,
};
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>;
}
function App() {
const [data, setData] = useState(emptyData);
const [tab, setTab] = useState('dashboard');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [dialog, setDialog] = useState(null);
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 totals=useMemo(()=>calc(data),[data]); const forecast=useMemo(()=>annualForecast(data),[data]);
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)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)});}
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}`);}}
async function togglePeriodic(){await persist({...data,settings:{...data.settings,includePeriodicInBalance:!(data.settings?.includePeriodicInBalance!==false)}});}
if(loading)return <div className="loading">FixFin wird geladen </div>;
const totals = useMemo(() => calc(data), [data]);
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>;
const nav = [
['dashboard', 'dashboard', 'Übersicht'],
['accounts', 'wallet', 'Konten'],
['incomes', 'plus', 'Eingänge'],
['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>}
{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}/>}
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']];
return <div className="app-shell">
<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>
<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>
{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}/>}
</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>
{dialog && <EditorDialog dialog={dialog} data={data} onClose={() => setDialog(null)} onSave={async next => { await persist(next); setDialog(null); }}/>}
</div>
);
<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);}}/>}
</div>;
}
function Dashboard({ totals }) {
function Dashboard({totals,onToggle}){
return <div className="content-stack">
<section className="summary-grid">
<Metric label="Fixe Eingänge / Monat" value={totals.income} positive />
<Metric label="Fixe Ausgänge / Monat" value={totals.expense} negative />
<Metric label="Transfers / Monat" value={totals.transferVolume} />
<Metric label="Monatlicher Gesamtsaldo" value={totals.balance} positive={totals.balance >= 0} negative={totals.balance < 0} featured />
</section>
<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>
<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>
<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>
{!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>}
<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>
<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 }) {
return <div className={`metric ${featured ? 'featured' : ''}`}><span>{label}</span><strong className={positive ? 'good-text' : negative ? 'bad-text' : ''}>{formatMoney(value)}</strong></div>
function YearPage({forecast}){
return <div className="content-stack"><section className="summary-grid year-summary"><Metric label="Einnahmen / Jahr" value={forecast.yearIncome} positive/><Metric label="Ausgaben / Jahr" value={forecast.yearExpense} negative/><Metric label="Saldo / Jahr" value={forecast.yearIncome-forecast.yearExpense} positive={forecast.yearIncome>=forecast.yearExpense} negative={forecast.yearIncome<forecast.yearExpense}/><Metric label="Nicht zugeordnet" value={forecast.unassigned.length}/></section>
<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>}
</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 Accounts({ data, onEdit, onDelete, onAdd }) {
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 EditorDialog({dialog,data,onClose,onSave}){
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 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);}}
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>
{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>}</>}
{(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>;
}
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 EditorDialog({ dialog, data, onClose, onSave }) {
const type = dialog.type;
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' };
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() };
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/>);
+22
View File
@@ -126,3 +126,25 @@ code { color:#b8c3ff; }
.summary-grid { grid-template-columns:1fr; }
.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; } }