diff --git a/README.md b/README.md
index 733e3f2..01a2d08 100644
--- a/README.md
+++ b/README.md
@@ -58,13 +58,13 @@ Danach: `http://DOCKER-SERVER-IP:3080`
Vor dem ersten Push:
```bash
-sudo docker login git.example.de
+docker login git.example.de
```
Dann:
```bash
-sudo ./build-and-push-gitea.sh git.example.de owner/fixfin latest
+./build-and-push-gitea.sh git.example.de owner/fixfin latest
```
Das Image wird als `git.example.de/owner/fixfin:latest` gepusht.
@@ -72,7 +72,7 @@ Das Image wird als `git.example.de/owner/fixfin:latest` gepusht.
Standardplattform ist `linux/amd64`. Abweichend z. B.:
```bash
-sudo PLATFORM=linux/arm64 ./build-and-push-gitea.sh git.example.de owner/fixfin latest
+PLATFORM=linux/arm64 ./build-and-push-gitea.sh git.example.de owner/fixfin latest
```
## Registry-Image deployen
@@ -97,3 +97,14 @@ Für nicht-monatliche Positionen kann optional ein Fälligkeitsmonat angegeben w
## Jahresansicht auf Mobilgeräten
Die Jahresprognose verwendet kompakte Monats-Akkordeons. Monat, Eingänge, Ausgänge und Saldo sind immer sichtbar. Fällige Einzelpositionen werden erst beim Aufklappen angezeigt. Die mobile Bottom-Navigation bleibt auf allen Ansichten identisch.
+
+## Finanzcockpit und Planung (v3.9)
+
+- Navigation: Eingänge, Ausgänge und Transfers sind unter **Bewegungen** zusammengefasst.
+- Übersicht: Kennzahlen, benötigte Zuführung je Konto und Rücklagenübersicht.
+- Statistik: Kategorie-Drilldown bis auf einzelne Fixpositionen. Bei einem gewählten Einzelkonto werden Transfers je nach Richtung den Eingängen oder Ausgängen zugerechnet.
+- Jahr: Belastungsprofil für die zwölf Monate zusätzlich zu den Monats-Akkordeons.
+- Szenarien: Was-wäre-wenn-Anpassungen für zusätzliche Eingänge, Ausgänge und Transfers, ohne den Basisplan zu verändern.
+- Bewegungen können dupliziert und anschließend angepasst werden.
+
+Das JSON-Datenmodell verwendet ab dieser Version `version: 6` und ergänzt das Array `scenarios`. Bestehende Daten werden automatisch migriert und vorher als `.bak` gesichert.
diff --git a/index.html b/index.html
index a0a1046..cfa4b7a 100644
--- a/index.html
+++ b/index.html
@@ -4,6 +4,13 @@
+
+
+
+
+
+
+
FixFin
diff --git a/package.json b/package.json
index 6bb8c52..cba78b1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "fixfin",
- "version": "1.3.1",
+ "version": "1.4.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png
new file mode 100644
index 0000000..cbff0b7
Binary files /dev/null and b/public/icons/apple-touch-icon.png differ
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 0000000..bba5b76
Binary files /dev/null and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 0000000..7fe2a01
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..59f8ef6
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,25 @@
+{
+ "name": "FixFin",
+ "short_name": "FixFin",
+ "description": "Fixkostenplanung für Konten, regelmäßige Zahlungen und Rücklagen.",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#090d14",
+ "theme_color": "#111827",
+ "orientation": "any",
+ "icons": [
+ {
+ "src": "/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..41cc831
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,46 @@
+const SHELL_CACHE='fixfin-shell-v3.14';
+const DATA_CACHE='fixfin-data-v1';
+const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png'];
+
+self.addEventListener('install',event=>{
+ event.waitUntil(caches.open(SHELL_CACHE).then(cache=>cache.addAll(APP_SHELL)).then(()=>self.skipWaiting()));
+});
+
+self.addEventListener('activate',event=>{
+ event.waitUntil(Promise.all([
+ caches.keys().then(keys=>Promise.all(keys.filter(key=>key.startsWith('fixfin-shell-')&&key!==SHELL_CACHE).map(key=>caches.delete(key)))),
+ self.clients.claim(),
+ ]));
+});
+
+self.addEventListener('fetch',event=>{
+ const request=event.request;
+ if(request.method!=='GET') return;
+ const url=new URL(request.url);
+ if(url.origin!==self.location.origin) return;
+
+ if(url.pathname==='/api/data') {
+ event.respondWith(fetch(request).then(response=>{
+ if(response.ok) caches.open(DATA_CACHE).then(cache=>cache.put(request,response.clone()));
+ return response;
+ }).catch(()=>caches.match(request).then(cached=>cached||new Response(JSON.stringify({error:'Offline und keine zwischengespeicherten Daten verfügbar.'}),{status:503,headers:{'Content-Type':'application/json'}}))));
+ return;
+ }
+ if(url.pathname.startsWith('/api/')) return;
+
+ if(request.mode==='navigate') {
+ event.respondWith(fetch(request).then(response=>{
+ caches.open(SHELL_CACHE).then(cache=>cache.put('/',response.clone()));
+ return response;
+ }).catch(()=>caches.match('/')));
+ return;
+ }
+
+ event.respondWith(caches.match(request).then(cached=>{
+ const network=fetch(request).then(response=>{
+ if(response.ok) caches.open(SHELL_CACHE).then(cache=>cache.put(request,response.clone()));
+ return response;
+ }).catch(()=>cached);
+ return cached||network;
+ }));
+});
diff --git a/server/index.js b/server/index.js
index b45a8c8..b794144 100644
--- a/server/index.js
+++ b/server/index.js
@@ -7,16 +7,61 @@ 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 validIntervals=new Set(['monthly','quarterly','semiannual','yearly']);
-const emptyData=()=>({version:5,settings:{includePeriodicInBalance:true},accounts:[],categories:[],incomes:[],expenses:[],transfers:[]});
+const validScenarioTypes=new Set(['income','expense','transfer']);
+const emptyData=()=>({version:6,settings:{includePeriodicInBalance:true},accounts:[],categories:[],incomes:[],expenses:[],transfers:[],scenarios:[]});
const isFiniteNumber=value=>typeof value==='number'&&Number.isFinite(value);
function normalizeMovement(item={}) { return { ...item, interval: validIntervals.has(item.interval)?item.interval:'monthly', dueMonth: item.interval && item.interval!=='monthly' && Number.isInteger(Number(item.dueMonth)) ? Number(item.dueMonth) : null }; }
function normalizeCategorizedMovement(item={}) { const normalized=normalizeMovement(item); return { ...normalized, categoryId: typeof item.categoryId==='string' && item.categoryId ? item.categoryId : null }; }
-function normalizeData(input){if(!input||typeof input!=='object')return input;return {version:5,settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false},accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts,categories:Array.isArray(input.categories)?input.categories.map(({id,name})=>({id,name})):[],incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeCategorizedMovement):input.incomes,expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeCategorizedMovement):input.expenses,transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeCategorizedMovement):input.transfers};}
+function normalizeScenarioAdjustment(item={}) {
+ const type=validScenarioTypes.has(item.type)?item.type:'expense';
+ const base={id:item.id,name:item.name,type,amount:Number(item.amount||0)};
+ if(type==='transfer') return {...base,fromAccountId:item.fromAccountId,toAccountId:item.toAccountId};
+ return {...base,accountId:item.accountId};
+}
+function normalizeScenario(item={}) { return {id:item.id,name:item.name,createdAt:typeof item.createdAt==='string'?item.createdAt:null,adjustments:Array.isArray(item.adjustments)?item.adjustments.map(normalizeScenarioAdjustment):[]}; }
+function normalizeData(input){
+ if(!input||typeof input!=='object')return input;
+ return {
+ version:6,
+ settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false},
+ accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts,
+ categories:Array.isArray(input.categories)?input.categories.map(({id,name})=>({id,name})):[],
+ incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeCategorizedMovement):input.incomes,
+ expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeCategorizedMovement):input.expenses,
+ transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeCategorizedMovement):input.transfers,
+ scenarios:Array.isArray(input.scenarios)?input.scenarios.map(normalizeScenario):[],
+ };
+}
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','categories','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);}const categoryIds=new Set();const categoryNames=new Set();for(const c of input.categories){if(!c.id||typeof c.id!=='string')throw new Error('Kategorie ohne gültige ID.');if(!c.name||typeof c.name!=='string'||!c.name.trim())throw new Error('Kategorie ohne Namen.');if(categoryIds.has(c.id))throw new Error('Doppelte Kategorie-ID.');const normalizedName=c.name.trim().toLocaleLowerCase('de-DE');if(categoryNames.has(normalizedName))throw new Error(`Kategorie '${c.name}' existiert bereits.`);categoryIds.add(c.id);categoryNames.add(normalizedName);}for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie bei '${item.name}'.`);}for(const t of input.transfers){validateMovement(t);if(!accountIds.has(t.fromAccountId)||!accountIds.has(t.toAccountId))throw new Error(`Unbekanntes Konto beim Transfer '${t.name}'.`);if(t.fromAccountId===t.toAccountId)throw new Error('Quell- und Zielkonto müssen verschieden sein.');if(t.categoryId!==null&&t.categoryId!==undefined&&!categoryIds.has(t.categoryId))throw new Error(`Unbekannte Kategorie beim Transfer '${t.name}'.`);}}
+function validateData(input){
+ if(!input||typeof input!=='object')throw new Error('Ungültiges JSON.');
+ for(const key of ['accounts','categories','incomes','expenses','transfers','scenarios'])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);}
+ const categoryIds=new Set(); const categoryNames=new Set();
+ for(const c of input.categories){if(!c.id||typeof c.id!=='string')throw new Error('Kategorie ohne gültige ID.');if(!c.name||typeof c.name!=='string'||!c.name.trim())throw new Error('Kategorie ohne Namen.');if(categoryIds.has(c.id))throw new Error('Doppelte Kategorie-ID.');const normalizedName=c.name.trim().toLocaleLowerCase('de-DE');if(categoryNames.has(normalizedName))throw new Error(`Kategorie '${c.name}' existiert bereits.`);categoryIds.add(c.id);categoryNames.add(normalizedName);}
+ for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie bei '${item.name}'.`);}
+ for(const t of input.transfers){validateMovement(t);if(!accountIds.has(t.fromAccountId)||!accountIds.has(t.toAccountId))throw new Error(`Unbekanntes Konto beim Transfer '${t.name}'.`);if(t.fromAccountId===t.toAccountId)throw new Error('Quell- und Zielkonto müssen verschieden sein.');if(t.categoryId!==null&&t.categoryId!==undefined&&!categoryIds.has(t.categoryId))throw new Error(`Unbekannte Kategorie beim Transfer '${t.name}'.`);}
+ const scenarioIds=new Set();
+ for(const s of input.scenarios){
+ if(!s.id||typeof s.id!=='string')throw new Error('Szenario ohne gültige ID.');
+ if(!s.name||typeof s.name!=='string'||!s.name.trim())throw new Error('Szenario ohne Namen.');
+ if(scenarioIds.has(s.id))throw new Error('Doppelte Szenario-ID.'); scenarioIds.add(s.id);
+ if(!Array.isArray(s.adjustments))throw new Error(`Szenario '${s.name}' hat ungültige Anpassungen.`);
+ const adjustmentIds=new Set();
+ for(const a of s.adjustments){
+ if(!a.id||typeof a.id!=='string'||adjustmentIds.has(a.id))throw new Error(`Ungültige Anpassungs-ID in '${s.name}'.`); adjustmentIds.add(a.id);
+ if(!a.name||typeof a.name!=='string'||!a.name.trim())throw new Error(`Anpassung ohne Namen in '${s.name}'.`);
+ if(!validScenarioTypes.has(a.type))throw new Error(`Ungültige Anpassungsart in '${s.name}'.`);
+ if(!isFiniteNumber(a.amount)||a.amount<0)throw new Error(`Ungültiger Szenario-Betrag bei '${a.name}'.`);
+ if(a.type==='transfer') { if(!accountIds.has(a.fromAccountId)||!accountIds.has(a.toAccountId))throw new Error(`Unbekanntes Konto bei Szenario-Transfer '${a.name}'.`); if(a.fromAccountId===a.toAccountId)throw new Error(`Quell- und Zielkonto bei '${a.name}' müssen verschieden sein.`); }
+ else if(!accountIds.has(a.accountId))throw new Error(`Unbekanntes Konto bei Szenario-Anpassung '${a.name}'.`);
+ }
+ }
+}
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!==5||!parsed.settings||!Array.isArray(parsed.categories)||[...parsed.incomes||[],...parsed.expenses||[],...parsed.transfers||[]].some(x=>!x.interval)||[...parsed.incomes||[],...parsed.expenses||[],...parsed.transfers||[]].some(x=>!Object.hasOwn(x,'categoryId'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;}
+async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];if(parsed.version!==6||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;}
async function 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}`);});
diff --git a/src/main.jsx b/src/main.jsx
index e5ab1ec..0b85ed8 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -10,7 +10,7 @@ const intervals = {
semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 },
yearly: { label: 'Jährlich', divisor: 12, step: 12 },
};
-const emptyData = { version: 5, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], incomes: [], expenses: [], transfers: [] };
+const emptyData = { version: 6, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], incomes: [], expenses: [], transfers: [], scenarios: [] };
function formatMoney(value) { return euro.format(Number(value || 0)); }
function parseAmount(value) {
@@ -32,6 +32,7 @@ function uid() {
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 annualEquivalent(item) { return monthlyEquivalent(item) * 12; }
function isPeriodic(item) { return (item.interval || 'monthly') !== 'monthly'; }
function balanceAmount(item, includePeriodic) {
if (!isPeriodic(item)) return Number(item.amount || 0);
@@ -46,27 +47,34 @@ function occursInMonth(item, monthIndex) {
const step = intervals[interval]?.step || 12;
return monthIndex >= start && (monthIndex - start) % step === 0;
}
+function categoryName(data, id) { return data.categories.find(c => c.id === id)?.name || 'Ohne Kategorie'; }
+function accountName(data, id) { return data.accounts.find(a => a.id === id)?.name || 'Unbekannt'; }
function calc(data) {
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 }]));
+ const accountStats = new Map(data.accounts.map(a => [a.id, { account: a, income: 0, expense: 0, transferIn: 0, transferOut: 0, periodicReserve: 0, balance: 0, requiredOutflow: 0, coveredInflow: 0, fundingNeed: 0, surplus: 0 }]));
for (const item of data.incomes) {
const stat = accountStats.get(item.accountId); if (!stat) continue;
- const value = balanceAmount(item, includePeriodic); stat.income += value;
- if (isPeriodic(item)) stat.periodicReserve += monthlyEquivalent(item);
+ stat.income += balanceAmount(item, includePeriodic);
}
for (const item of data.expenses) {
const stat = accountStats.get(item.accountId); if (!stat) continue;
- const value = balanceAmount(item, includePeriodic); stat.expense += value;
+ stat.expense += balanceAmount(item, includePeriodic);
if (isPeriodic(item)) stat.periodicReserve += monthlyEquivalent(item);
}
for (const item of data.transfers) {
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;
+ 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;
+ stat.requiredOutflow = stat.expense + stat.transferOut;
+ stat.coveredInflow = stat.income + stat.transferIn;
+ stat.fundingNeed = Math.max(0, stat.requiredOutflow - stat.coveredInflow);
+ stat.surplus = Math.max(0, stat.coveredInflow - stat.requiredOutflow);
}
- 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);
@@ -81,45 +89,31 @@ function annualForecast(data, accountId = 'all') {
const relevantIncome = item => isAll || item.accountId === accountId;
const relevantExpense = item => isAll || item.accountId === accountId;
const relevantTransfer = item => isAll || item.fromAccountId === accountId || item.toAccountId === accountId;
-
for (const item of data.incomes.filter(relevantIncome)) {
- for (let m=0;m<12;m++) if (occursInMonth(item,m)) {
- rows[m].income += Number(item.amount || 0);
- rows[m].items.push({ ...item, kind:'income' });
- }
+ for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].income += Number(item.amount || 0); rows[m].items.push({ ...item, kind:'income' }); }
}
for (const item of data.expenses.filter(relevantExpense)) {
- for (let m=0;m<12;m++) if (occursInMonth(item,m)) {
- rows[m].expense += Number(item.amount || 0);
- rows[m].items.push({ ...item, kind:'expense' });
- }
+ for (let m=0;m<12;m++) if (occursInMonth(item,m)) { rows[m].expense += Number(item.amount || 0); rows[m].items.push({ ...item, kind:'expense' }); }
}
for (const item of data.transfers.filter(relevantTransfer)) {
for (let m=0;m<12;m++) if (occursInMonth(item,m)) {
const amount = Number(item.amount || 0);
if (isAll) {
- rows[m].transferIn += amount;
- rows[m].transferOut += amount;
- rows[m].items.push({ ...item, kind:'transfer', direction:'neutral' });
+ rows[m].transferIn += amount; rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'neutral' });
} else {
- if (item.toAccountId === accountId) {
- rows[m].transferIn += amount;
- rows[m].items.push({ ...item, kind:'transfer', direction:'in' });
- }
- if (item.fromAccountId === accountId) {
- rows[m].transferOut += amount;
- rows[m].items.push({ ...item, kind:'transfer', direction:'out' });
- }
+ if (item.toAccountId === accountId) { rows[m].transferIn += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'in' }); }
+ if (item.fromAccountId === accountId) { rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'out' }); }
}
}
}
-
rows.forEach(r => {
- r.balance = r.income - r.expense + (isAll ? 0 : r.transferIn - r.transferOut);
- r.displayIncome = r.income + (isAll ? 0 : r.transferIn);
- r.displayExpense = r.expense + (isAll ? 0 : r.transferOut);
+ // Zwei Perspektiven:
+ // Gesamtansicht = wirtschaftliche Sicht, Transfers bleiben saldoneutral.
+ // Einzelkonto = Liquiditätssicht, Transfers sind echte Zu-/Abflüsse des Kontos.
+ r.displayIncome = isAll ? r.income : r.income + r.transferIn;
+ r.displayExpense = isAll ? r.expense : r.expense + r.transferOut;
+ r.balance = r.displayIncome - r.displayExpense;
});
-
const hasMonth = x => Number.isInteger(Number(x.dueMonth)) && Number(x.dueMonth) >= 1 && Number(x.dueMonth) <= 12;
const unassigned = [
...data.incomes.filter(relevantIncome).map(x => ({...x, kind:'income'})),
@@ -132,7 +126,6 @@ function annualForecast(data, accountId = 'all') {
return entries;
}),
].filter(x => isPeriodic(x) && !hasMonth(x));
-
const yearIncome = rows.reduce((s,r)=>s+r.displayIncome,0);
const yearExpense = rows.reduce((s,r)=>s+r.displayExpense,0);
return { rows, unassigned, yearIncome, yearExpense, isAll, accountId };
@@ -146,115 +139,270 @@ function categoryStats(data, accountId = 'all') {
for (const item of items) {
const key = item.categoryId || '__uncategorized__';
const name = categoryMap.get(item.categoryId) || 'Ohne Kategorie';
- const current = buckets.get(key) || { id:key, name, amount:0, count:0, uncategorized:key==='__uncategorized__' };
- current.amount += monthlyEquivalent(item);
- current.count += 1;
- buckets.set(key,current);
+ const current = buckets.get(key) || { id:key, name, amount:0, count:0, items:[], uncategorized:key==='__uncategorized__' };
+ const amount = monthlyEquivalent(item);
+ current.amount += amount; current.count += 1; current.items.push({...item, statAmount:amount}); buckets.set(key,current);
}
const total = [...buckets.values()].reduce((sum,x)=>sum+x.amount,0);
const rows = [...buckets.values()].sort((a,b)=>b.amount-a.amount).map(x=>({...x,percent:total>0?(x.amount/total)*100:0}));
return { total, rows };
};
-
- if (isAll) {
- return {
- incomes: aggregate(data.incomes),
- expenses: aggregate(data.expenses),
- transfers: aggregate(data.transfers),
- transfersMerged: false,
- };
- }
-
- // Bei einer Einzelkonto-Auswertung sind Transfers echte Zu-/Abflüsse
- // dieses Kontos: Zielkonto = Eingang, Quellkonto = Ausgang.
+ if (isAll) return { incomes: aggregate(data.incomes.map(x=>({...x,_statType:'income'}))), expenses: aggregate(data.expenses.map(x=>({...x,_statType:'expense'}))), transfers: aggregate(data.transfers.map(x=>({...x,_statType:'transfer'}))), transfersMerged:false };
const incomeItems = [
- ...data.incomes.filter(x => x.accountId === accountId),
- ...data.transfers.filter(x => x.toAccountId === accountId),
+ ...data.incomes.filter(x => x.accountId === accountId).map(x=>({...x,_statType:'income'})),
+ ...data.transfers.filter(x => x.toAccountId === accountId).map(x=>({...x,_statType:'transfer-in'})),
];
const expenseItems = [
- ...data.expenses.filter(x => x.accountId === accountId),
- ...data.transfers.filter(x => x.fromAccountId === accountId),
+ ...data.expenses.filter(x => x.accountId === accountId).map(x=>({...x,_statType:'expense'})),
+ ...data.transfers.filter(x => x.fromAccountId === accountId).map(x=>({...x,_statType:'transfer-out'})),
];
+ return { incomes: aggregate(incomeItems), expenses: aggregate(expenseItems), transfers:{total:0,rows:[]}, transfersMerged:true };
+}
+function dashboardKpis(data, totals) {
+ const forecast = annualForecast(data, 'all');
+ const expensive = [...forecast.rows].sort((a,b)=>b.displayExpense-a.displayExpense)[0];
+ const cheap = [...forecast.rows].sort((a,b)=>a.displayExpense-b.displayExpense)[0];
return {
- incomes: aggregate(incomeItems),
- expenses: aggregate(expenseItems),
- transfers: { total:0, rows:[] },
- transfersMerged: true,
+ fixedCostRatio: totals.income > 0 ? totals.expense / totals.income * 100 : 0,
+ annualExpense: data.expenses.reduce((s,x)=>s+annualEquivalent(x),0),
+ annualIncome: data.incomes.reduce((s,x)=>s+annualEquivalent(x),0),
+ freeMonthly: totals.balance,
+ reserveMonthly: totals.periodicExpenseReserve,
+ expensive,
+ cheap,
};
}
+function reserveOverview(data) {
+ const items = data.expenses.filter(isPeriodic).map(x=>({ ...x, monthlyReserve:monthlyEquivalent(x), annualNeed:annualEquivalent(x) })).sort((a,b)=>b.monthlyReserve-a.monthlyReserve);
+ return { items, monthly:items.reduce((s,x)=>s+x.monthlyReserve,0), annual:items.reduce((s,x)=>s+x.annualNeed,0) };
+}
+
+function scenarioData(data, scenario) {
+ if (!scenario) return data;
+ const next = { ...data, incomes:[...data.incomes], expenses:[...data.expenses], transfers:[...data.transfers] };
+ for (const a of scenario.adjustments || []) {
+ const common = { id:`scenario-${scenario.id}-${a.id}`, name:a.name, amount:Number(a.amount||0), interval:'monthly', dueMonth:null, categoryId:a.categoryId || null };
+ if (a.type === 'income') next.incomes.push({...common,accountId:a.accountId});
+ if (a.type === 'expense') next.expenses.push({...common,accountId:a.accountId});
+ if (a.type === 'transfer') next.transfers.push({...common,fromAccountId:a.fromAccountId,toAccountId:a.toAccountId});
+ }
+ return next;
+}
+
function Icon({ name }) {
const paths = {
dashboard:<>>,
wallet:<>>,
- plus:<>>, minus:<>>,
transfer:<>>,
calendar:<>>,
chart:<>>,
+ flask:<>>,
+ planning:<>>,
data:<>>,
};
return ;
}
function App() {
- 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 [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 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)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)});}
+ 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);return 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)||(data.scenarios||[]).some(s=>(s.adjustments||[]).some(x=>x.accountId===id||x.fromAccountId===id||x.toAccountId===id));if(used)return setError('Das Konto wird noch verwendet. Einträge, Transfers oder Szenario-Anpassungen 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)});}
- function removeCategory(id){const used=[...data.incomes,...data.expenses,...data.transfers].filter(x=>x.categoryId===id).length;const message=used?`Kategorie wird bei ${used} ${used===1?'Eintrag':'Einträgen'} entfernt. Kategorie wirklich löschen?`:'Kategorie wirklich löschen?';if(!confirm(message))return;persist({...data,categories:data.categories.filter(x=>x.id!==id),incomes:data.incomes.map(x=>x.categoryId===id?{...x,categoryId:null}:x),expenses:data.expenses.map(x=>x.categoryId===id?{...x,categoryId:null}:x),transfers:data.transfers.map(x=>x.categoryId===id?{...x,categoryId:null}:x)});}
+ function removeCategory(id){const used=[...data.incomes,...data.expenses,...data.transfers].filter(x=>x.categoryId===id).length;const message=used?`Kategorie wird bei ${used} ${used===1?'Eintrag':'Einträgen'} entfernt. Kategorie wirklich löschen?`:'Kategorie wirklich löschen?';if(!confirm(message))return;persist({...data,categories:data.categories.filter(x=>x.id!==id),incomes:data.incomes.map(x=>x.categoryId===id?{...x,categoryId:null}:x),expenses:data.expenses.map(x=>x.categoryId===id?{...x,categoryId:null}:x),transfers:data.transfers.map(x=>x.categoryId===id?{...x,categoryId:null}:x),scenarios:(data.scenarios||[]).map(s=>({...s,adjustments:(s.adjustments||[]).map(x=>x.categoryId===id?{...x,categoryId:null}:x)}))});}
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
{forecast.isAll?'Gesamt über alle Konten. Transfers bleiben saldoneutral.':`Prognose für ${selectedName}. Transfers werden als Zu- oder Abfluss dieses Kontos berücksichtigt.`}
{forecast.isAll?'Echte Ausgaben pro Monat. Interne Transfers bleiben außen vor.':`Was ${selectedName} pro Monat tatsächlich verlässt: echte Ausgaben plus Transfers raus.`}
{forecast.rows.map(r=>
{r.name.slice(0,3)}
{formatMoney(r.displayExpense)}
)}
+
+
Jahresprognose
{forecast.isAll?'Wirtschaftliche Sicht über alle Konten: nur echte Einnahmen und Ausgaben. Transfers verschieben Geld nur innerhalb des Zahlungspools.':`Liquiditätssicht für ${selectedName}: Transfers rein zählen als Zufluss, Transfers raus als Abfluss. Im Detail bleiben sie klar von echten Einnahmen und Ausgaben getrennt.`}
Interne Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie bleiben vollständig außerhalb von Einnahmen, Ausgaben und Überschuss.
}>:<>
+
+
Echte Eingänge+{formatMoney(r.income)}
+
Transfers rein+{formatMoney(r.transferIn)}
+
Echte Ausgaben−{formatMoney(r.expense)}
+
Transfers raus−{formatMoney(r.transferOut)}
+
+ {inflows.length>0&&
Zuflüsse+{formatMoney(r.displayIncome)}
{inflows.map(detailRow)}
}
+ {outflows.length>0&&
Abflüsse−{formatMoney(r.displayExpense)}
{outflows.map(detailRow)}
}
+
Kontosicht: Transfers werden im Nettofluss berücksichtigt, bleiben aber als Transfers gekennzeichnet. So siehst du den echten Liquiditätsbedarf des Kontos, ohne sie mit Konsumausgaben zu verwechseln.
+ >}
- ⌄
-
-
- {r.items.length===0?
In diesem Monat sind keine einzelnen Fixpositionen fällig.