V3.14
This commit is contained in:
@@ -58,13 +58,13 @@ Danach: `http://DOCKER-SERVER-IP:3080`
|
|||||||
Vor dem ersten Push:
|
Vor dem ersten Push:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo docker login git.example.de
|
docker login git.example.de
|
||||||
```
|
```
|
||||||
|
|
||||||
Dann:
|
Dann:
|
||||||
|
|
||||||
```bash
|
```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.
|
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.:
|
Standardplattform ist `linux/amd64`. Abweichend z. B.:
|
||||||
|
|
||||||
```bash
|
```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
|
## Registry-Image deployen
|
||||||
@@ -97,3 +97,14 @@ Für nicht-monatliche Positionen kann optional ein Fälligkeitsmonat angegeben w
|
|||||||
## Jahresansicht auf Mobilgeräten
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#111827" />
|
<meta name="theme-color" content="#111827" />
|
||||||
|
<meta name="application-name" content="FixFin" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="FixFin" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="icon" href="/icons/icon-192.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
<title>FixFin</title>
|
<title>FixFin</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fixfin",
|
"name": "fixfin",
|
||||||
"version": "1.3.1",
|
"version": "1.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 733 B |
Binary file not shown.
|
After Width: | Height: | Size: 796 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}));
|
||||||
|
});
|
||||||
+49
-4
@@ -7,16 +7,61 @@ import { fileURLToPath } from 'node:url';
|
|||||||
const __filename=fileURLToPath(import.meta.url); const __dirname=path.dirname(__filename); const app=express();
|
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 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 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);
|
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 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 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 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 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);}
|
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);}});
|
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}`);});
|
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}`);});
|
||||||
|
|||||||
+334
-127
@@ -10,7 +10,7 @@ const intervals = {
|
|||||||
semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 },
|
semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 },
|
||||||
yearly: { label: 'Jährlich', divisor: 12, step: 12 },
|
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 formatMoney(value) { return euro.format(Number(value || 0)); }
|
||||||
function parseAmount(value) {
|
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)}`;
|
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 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 isPeriodic(item) { return (item.interval || 'monthly') !== 'monthly'; }
|
||||||
function balanceAmount(item, includePeriodic) {
|
function balanceAmount(item, includePeriodic) {
|
||||||
if (!isPeriodic(item)) return Number(item.amount || 0);
|
if (!isPeriodic(item)) return Number(item.amount || 0);
|
||||||
@@ -46,27 +47,34 @@ function occursInMonth(item, monthIndex) {
|
|||||||
const step = intervals[interval]?.step || 12;
|
const step = intervals[interval]?.step || 12;
|
||||||
return monthIndex >= start && (monthIndex - start) % step === 0;
|
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) {
|
function calc(data) {
|
||||||
const includePeriodic = data.settings?.includePeriodicInBalance !== false;
|
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) {
|
for (const item of data.incomes) {
|
||||||
const stat = accountStats.get(item.accountId); if (!stat) continue;
|
const stat = accountStats.get(item.accountId); if (!stat) continue;
|
||||||
const value = balanceAmount(item, includePeriodic); stat.income += value;
|
stat.income += balanceAmount(item, includePeriodic);
|
||||||
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); if (!stat) continue;
|
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);
|
if (isPeriodic(item)) stat.periodicReserve += monthlyEquivalent(item);
|
||||||
}
|
}
|
||||||
for (const item of data.transfers) {
|
for (const item of data.transfers) {
|
||||||
const value = balanceAmount(item, includePeriodic);
|
const value = balanceAmount(item, includePeriodic);
|
||||||
const from = accountStats.get(item.fromAccountId); const to = accountStats.get(item.toAccountId);
|
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 income = data.incomes.reduce((s,x) => s + balanceAmount(x, includePeriodic), 0);
|
||||||
const expense = data.expenses.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 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 relevantIncome = item => isAll || item.accountId === accountId;
|
||||||
const relevantExpense = item => isAll || item.accountId === accountId;
|
const relevantExpense = item => isAll || item.accountId === accountId;
|
||||||
const relevantTransfer = item => isAll || item.fromAccountId === accountId || item.toAccountId === accountId;
|
const relevantTransfer = item => isAll || item.fromAccountId === accountId || item.toAccountId === accountId;
|
||||||
|
|
||||||
for (const item of data.incomes.filter(relevantIncome)) {
|
for (const item of data.incomes.filter(relevantIncome)) {
|
||||||
for (let m=0;m<12;m++) if (occursInMonth(item,m)) {
|
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' }); }
|
||||||
rows[m].income += Number(item.amount || 0);
|
|
||||||
rows[m].items.push({ ...item, kind:'income' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (const item of data.expenses.filter(relevantExpense)) {
|
for (const item of data.expenses.filter(relevantExpense)) {
|
||||||
for (let m=0;m<12;m++) if (occursInMonth(item,m)) {
|
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' }); }
|
||||||
rows[m].expense += Number(item.amount || 0);
|
|
||||||
rows[m].items.push({ ...item, kind:'expense' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (const item of data.transfers.filter(relevantTransfer)) {
|
for (const item of data.transfers.filter(relevantTransfer)) {
|
||||||
for (let m=0;m<12;m++) if (occursInMonth(item,m)) {
|
for (let m=0;m<12;m++) if (occursInMonth(item,m)) {
|
||||||
const amount = Number(item.amount || 0);
|
const amount = Number(item.amount || 0);
|
||||||
if (isAll) {
|
if (isAll) {
|
||||||
rows[m].transferIn += amount;
|
rows[m].transferIn += amount; rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'neutral' });
|
||||||
rows[m].transferOut += amount;
|
|
||||||
rows[m].items.push({ ...item, kind:'transfer', direction:'neutral' });
|
|
||||||
} else {
|
} else {
|
||||||
if (item.toAccountId === accountId) {
|
if (item.toAccountId === accountId) { rows[m].transferIn += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'in' }); }
|
||||||
rows[m].transferIn += amount;
|
if (item.fromAccountId === accountId) { rows[m].transferOut += amount; rows[m].items.push({ ...item, kind:'transfer', direction:'out' }); }
|
||||||
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 => {
|
rows.forEach(r => {
|
||||||
r.balance = r.income - r.expense + (isAll ? 0 : r.transferIn - r.transferOut);
|
// Zwei Perspektiven:
|
||||||
r.displayIncome = r.income + (isAll ? 0 : r.transferIn);
|
// Gesamtansicht = wirtschaftliche Sicht, Transfers bleiben saldoneutral.
|
||||||
r.displayExpense = r.expense + (isAll ? 0 : r.transferOut);
|
// 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 hasMonth = x => Number.isInteger(Number(x.dueMonth)) && Number(x.dueMonth) >= 1 && Number(x.dueMonth) <= 12;
|
||||||
const unassigned = [
|
const unassigned = [
|
||||||
...data.incomes.filter(relevantIncome).map(x => ({...x, kind:'income'})),
|
...data.incomes.filter(relevantIncome).map(x => ({...x, kind:'income'})),
|
||||||
@@ -132,7 +126,6 @@ function annualForecast(data, accountId = 'all') {
|
|||||||
return entries;
|
return entries;
|
||||||
}),
|
}),
|
||||||
].filter(x => isPeriodic(x) && !hasMonth(x));
|
].filter(x => isPeriodic(x) && !hasMonth(x));
|
||||||
|
|
||||||
const yearIncome = rows.reduce((s,r)=>s+r.displayIncome,0);
|
const yearIncome = rows.reduce((s,r)=>s+r.displayIncome,0);
|
||||||
const yearExpense = rows.reduce((s,r)=>s+r.displayExpense,0);
|
const yearExpense = rows.reduce((s,r)=>s+r.displayExpense,0);
|
||||||
return { rows, unassigned, yearIncome, yearExpense, isAll, accountId };
|
return { rows, unassigned, yearIncome, yearExpense, isAll, accountId };
|
||||||
@@ -146,115 +139,270 @@ function categoryStats(data, accountId = 'all') {
|
|||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const key = item.categoryId || '__uncategorized__';
|
const key = item.categoryId || '__uncategorized__';
|
||||||
const name = categoryMap.get(item.categoryId) || 'Ohne Kategorie';
|
const name = categoryMap.get(item.categoryId) || 'Ohne Kategorie';
|
||||||
const current = buckets.get(key) || { id:key, name, amount:0, count:0, uncategorized:key==='__uncategorized__' };
|
const current = buckets.get(key) || { id:key, name, amount:0, count:0, items:[], uncategorized:key==='__uncategorized__' };
|
||||||
current.amount += monthlyEquivalent(item);
|
const amount = monthlyEquivalent(item);
|
||||||
current.count += 1;
|
current.amount += amount; current.count += 1; current.items.push({...item, statAmount:amount}); buckets.set(key,current);
|
||||||
buckets.set(key,current);
|
|
||||||
}
|
}
|
||||||
const total = [...buckets.values()].reduce((sum,x)=>sum+x.amount,0);
|
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}));
|
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 };
|
return { total, rows };
|
||||||
};
|
};
|
||||||
|
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 };
|
||||||
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.
|
|
||||||
const incomeItems = [
|
const incomeItems = [
|
||||||
...data.incomes.filter(x => x.accountId === accountId),
|
...data.incomes.filter(x => x.accountId === accountId).map(x=>({...x,_statType:'income'})),
|
||||||
...data.transfers.filter(x => x.toAccountId === accountId),
|
...data.transfers.filter(x => x.toAccountId === accountId).map(x=>({...x,_statType:'transfer-in'})),
|
||||||
];
|
];
|
||||||
const expenseItems = [
|
const expenseItems = [
|
||||||
...data.expenses.filter(x => x.accountId === accountId),
|
...data.expenses.filter(x => x.accountId === accountId).map(x=>({...x,_statType:'expense'})),
|
||||||
...data.transfers.filter(x => x.fromAccountId === accountId),
|
...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 {
|
return {
|
||||||
incomes: aggregate(incomeItems),
|
fixedCostRatio: totals.income > 0 ? totals.expense / totals.income * 100 : 0,
|
||||||
expenses: aggregate(expenseItems),
|
annualExpense: data.expenses.reduce((s,x)=>s+annualEquivalent(x),0),
|
||||||
transfers: { total:0, rows:[] },
|
annualIncome: data.incomes.reduce((s,x)=>s+annualEquivalent(x),0),
|
||||||
transfersMerged: true,
|
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 }) {
|
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"/></>, 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"/></>,
|
calendar:<><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 10h18"/></>,
|
||||||
chart:<><path d="M4 20V10"/><path d="M10 20V4"/><path d="M16 20v-7"/><path d="M22 20H2"/></>,
|
chart:<><path d="M4 20V10"/><path d="M10 20V4"/><path d="M16 20v-7"/><path d="M22 20H2"/></>,
|
||||||
|
flask:<><path d="M9 3h6"/><path d="M10 3v6l-5 8a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-5-8V3"/><path d="M8 14h8"/></>,
|
||||||
|
planning:<><path d="M4 19V9"/><path d="M10 19V5"/><path d="M16 19v-7"/><path d="M22 19H2"/><path d="m4 7 6-4 6 6 5-4"/></>,
|
||||||
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), [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]);
|
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)); },[]);
|
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);}}
|
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);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 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 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 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)}});}
|
async function togglePeriodic(){await persist({...data,settings:{...data.settings,includePeriodicInBalance:!(data.settings?.includePeriodicInBalance!==false)}});}
|
||||||
if(loading)return <div className="loading">FixFin wird geladen …</div>;
|
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'],['year','calendar','Jahr'],['stats','chart','Statistik'],['data','data','Daten']];
|
const nav=[['dashboard','dashboard','Übersicht'],['accounts','wallet','Konten'],['movements','transfer','Bewegungen'],['year','calendar','Jahr'],['planning','planning','Planung'],['stats','chart','Statistik'],['scenarios','flask','Szenarien'],['data','data','Daten']];
|
||||||
return <div className="app-shell">
|
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>
|
<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','stats','data'].includes(tab)&&<button className="primary" onClick={()=>setDialog({type:tab})}>+ Neu</button>}</header>
|
<main><header className="topbar"><div><h1>{nav.find(x=>x[0]===tab)?.[2]}</h1><p>Fixe Einnahmen, Ausgaben und Transfers ohne Buchungsverlauf.</p></div>{tab==='accounts'&&<button className="primary" onClick={()=>setDialog({type:'accounts'})}>+ Konto</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} categories={data.categories} onEdit={x=>setDialog({type:'incomes',item:x})} onDelete={removeItem}/>} {tab==='expenses'&&<Entries type="expenses" items={data.expenses} accounts={data.accounts} categories={data.categories} 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 data={data}/>} {tab==='stats'&&<StatisticsPage data={data} onAddCategory={()=>setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>} {tab==='data'&&<DataPage data={data} importFile={importFile}/>}
|
{tab==='dashboard'&&<Dashboard data={data} totals={totals} onToggle={togglePeriodic}/>}
|
||||||
|
{tab==='accounts'&&<Accounts data={data} onEdit={x=>setDialog({type:'accounts',item:x})} onDelete={removeAccount} onAdd={()=>setDialog({type:'accounts'})}/>}
|
||||||
|
{tab==='movements'&&<MovementsPage data={data} onEdit={(type,x)=>setDialog({type,item:x})} onDuplicate={(type,x)=>setDialog({type,item:{...x,id:null},duplicate:true})} onDelete={removeItem} onAdd={type=>setDialog({type})}/>}
|
||||||
|
{tab==='year'&&<YearPage data={data}/>}
|
||||||
|
{tab==='planning'&&<PlanningPage data={data} totals={totals}/>}
|
||||||
|
{tab==='stats'&&<StatisticsPage data={data} onAddCategory={()=>setDialog({type:'categories'})} onEditCategory={x=>setDialog({type:'categories',item:x})} onDeleteCategory={removeCategory}/>}
|
||||||
|
{tab==='scenarios'&&<ScenariosPage data={data} persist={persist}/>}
|
||||||
|
{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,onToggle}){
|
function Dashboard({data,totals,onToggle}){
|
||||||
return <div className="content-stack">
|
const FILTER_KEY='fixfin.dashboard.account-filter.v1';
|
||||||
<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>
|
const [filter,setFilter]=useState(()=>{
|
||||||
<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>
|
try {
|
||||||
|
const stored=JSON.parse(localStorage.getItem(FILTER_KEY)||'null');
|
||||||
|
if(stored?.mode==='some'&&Array.isArray(stored.ids)) return {mode:'some',ids:stored.ids};
|
||||||
|
} catch {}
|
||||||
|
return {mode:'all',ids:[]};
|
||||||
|
});
|
||||||
|
const accountIds=useMemo(()=>data.accounts.map(a=>a.id),[data.accounts]);
|
||||||
|
const validSelected=useMemo(()=>filter.mode==='all'?accountIds:filter.ids.filter(id=>accountIds.includes(id)),[filter,accountIds]);
|
||||||
|
const selectedSet=useMemo(()=>new Set(validSelected),[validSelected]);
|
||||||
|
const visibleAccounts=useMemo(()=>totals.accounts.filter(s=>selectedSet.has(s.account.id)),[totals.accounts,selectedSet]);
|
||||||
|
const selectedBalance=visibleAccounts.reduce((sum,s)=>sum+s.balance,0);
|
||||||
|
const allSelected=filter.mode==='all'||validSelected.length===accountIds.length;
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
if(filter.mode==='some'&&accountIds.length>0&&validSelected.length===0) setFilter({mode:'all',ids:[]});
|
||||||
|
},[accountIds.length,filter.mode,validSelected.length]);
|
||||||
|
useEffect(()=>{
|
||||||
|
try { localStorage.setItem(FILTER_KEY,JSON.stringify(allSelected?{mode:'all',ids:[]}:{mode:'some',ids:validSelected})); } catch {}
|
||||||
|
},[allSelected,validSelected]);
|
||||||
|
|
||||||
|
function toggleAccount(id){
|
||||||
|
// Wenn aktuell alle Konten aktiv sind, bedeutet ein Tap auf ein Konto:
|
||||||
|
// sofort nur dieses Konto anzeigen. So braucht die Einzelauswahl nur einen Tap.
|
||||||
|
if(allSelected){
|
||||||
|
setFilter({mode:'some',ids:[id]});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const base=[...validSelected];
|
||||||
|
const next=base.includes(id)?base.filter(x=>x!==id):[...base,id];
|
||||||
|
if(next.length===0) return;
|
||||||
|
if(next.length===accountIds.length) setFilter({mode:'all',ids:[]});
|
||||||
|
else setFilter({mode:'some',ids:next});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterLabel=allSelected?'Alle Konten':`${validSelected.length} von ${accountIds.length} Konten`;
|
||||||
|
return <div className="content-stack dashboard-page">
|
||||||
|
<details className="dashboard-account-filter">
|
||||||
|
<summary><div><span>Kontenfilter</span><strong>{filterLabel}</strong></div><span className="accordion-chevron" aria-hidden="true">⌄</span></summary>
|
||||||
|
<div className="dashboard-filter-options">
|
||||||
|
<button type="button" className={`filter-chip ${allSelected?'active':''}`} onClick={()=>setFilter({mode:'all',ids:[]})}>Alle</button>
|
||||||
|
{data.accounts.map(a=><button type="button" key={a.id} className={`filter-chip ${selectedSet.has(a.id)?'active':''}`} aria-pressed={selectedSet.has(a.id)} onClick={()=>toggleAccount(a.id)}>{a.name}</button>)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<section className="panel dashboard-balances"><div className="section-head"><div><h2>Salden</h2><p>{allSelected?'Alle Konten im monatlichen Fix-Saldo.':'Nur die ausgewählten Konten.'}</p></div><div className="dashboard-selected-total"><span>{allSelected?'Gesamtsaldo':'Saldo Auswahl'}</span><strong className={selectedBalance>=0?'good-text':'bad-text'}>{selectedBalance>=0?'+':''}{formatMoney(selectedBalance)}</strong></div></div>
|
||||||
|
{visibleAccounts.length===0?<Empty text="Keine Konten ausgewählt."/>:<div className="account-balance-list">{visibleAccounts.map(s=><details className="account-balance-card" key={s.account.id}><summary><div className="account-balance-title"><h3>{s.account.name}</h3><span>monatlicher Fix-Saldo</span></div><div className="account-balance-right"><strong className={`account-balance-value ${s.balance>=0?'good-text':'bad-text'}`}>{s.balance>=0?'+':''}{formatMoney(s.balance)}</strong><span className="accordion-chevron" aria-hidden="true">⌄</span></div></summary><div className="account-balance-details"><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></div></details>)}</div>}
|
||||||
|
</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>}
|
{!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-balance-list">{totals.accounts.map(s=><details className="account-balance-card" key={s.account.id}><summary><div className="account-balance-title"><h3>{s.account.name}</h3><span>monatlicher Fix-Saldo</span></div><div className="account-balance-right"><strong className={`account-balance-value ${s.balance>=0?'good-text':'bad-text'}`}>{s.balance>=0?'+':''}{formatMoney(s.balance)}</strong><span className="accordion-chevron" aria-hidden="true">⌄</span></div></summary><div className="account-balance-details"><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></div></details>)}</div>}</section>
|
<section className="toggle-panel dashboard-toggle"><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="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>;
|
</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 CountMetric({label,value}){return <div className="metric"><span>{label}</span><strong>{Number(value||0).toLocaleString('de-DE')}</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,categories,onEdit,onDelete}){const accountName=id=>accounts.find(a=>a.id===id)?.name||'Unbekannt';const categoryName=id=>categories.find(c=>c.id===id)?.name||'Ohne Kategorie';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>Kategorie</th><th>Konto</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{items.map(x=><tr key={x.id}><td><strong>{x.name}</strong></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryName(x.categoryId)}</span></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';const categoryName=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie';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>Kategorie</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><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryName(x.categoryId)}</span></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 CategoryDistribution({title,subtitle,stats,tone}){
|
function PlanningPage({data,totals}){
|
||||||
|
const reserves=useMemo(()=>reserveOverview(data),[data]);
|
||||||
|
const totalRequired=totals.accounts.reduce((s,x)=>s+x.requiredOutflow,0);
|
||||||
|
const totalFundingNeed=totals.accounts.reduce((s,x)=>s+x.fundingNeed,0);
|
||||||
|
return <div className="content-stack planning-page">
|
||||||
|
<section className="summary-grid planning-summary">
|
||||||
|
<Metric label="Abflussbedarf aller Konten / Monat" value={totalRequired}/>
|
||||||
|
<Metric label="Zusätzliche Zuführung" value={totalFundingNeed} negative={totalFundingNeed>0} positive={totalFundingNeed===0}/>
|
||||||
|
<Metric label="Rücklage / Monat" value={reserves.monthly}/>
|
||||||
|
<Metric label="Periodischer Bedarf / Jahr" value={reserves.annual}/>
|
||||||
|
</section>
|
||||||
|
<section className="panel"><div className="section-head"><div><h2>Benötigt pro Konto</h2><p>Liquiditätsbedarf je Konto: echte Ausgaben plus Transfers raus, gedeckt durch echte Eingänge plus Transfers rein.</p></div></div>{totals.accounts.length===0?<Empty text="Lege zuerst ein Konto an."/>:<div className="funding-grid">{totals.accounts.map(s=><div className="funding-card" key={s.account.id}><div className="funding-head"><strong>{s.account.name}</strong><span className={s.fundingNeed>0?'bad-text':'good-text'}>{s.fundingNeed>0?`${formatMoney(s.fundingNeed)} fehlen`:`${formatMoney(s.surplus)} übrig`}</span></div><div className="funding-numbers"><div><span>Abflussbedarf</span><strong>{formatMoney(s.requiredOutflow)}</strong></div><div><span>Geplante Zuflüsse</span><strong>{formatMoney(s.coveredInflow)}</strong></div><div><span>Zusätzliche Zuführung</span><strong className={s.fundingNeed>0?'bad-text':'good-text'}>{formatMoney(s.fundingNeed)}</strong></div></div></div>)}</div>}</section>
|
||||||
|
<section className="panel reserve-panel"><div className="section-head"><div><h2>Rücklagenübersicht</h2><p>Periodische Ausgaben auf einen gleichmäßigen Monatsbetrag heruntergerechnet.</p></div><div className="reserve-total"><span>{formatMoney(reserves.annual)} / Jahr</span><strong>{formatMoney(reserves.monthly)} / Monat</strong></div></div>{reserves.items.length===0?<Empty text="Keine periodischen Ausgaben vorhanden."/>:<div className="reserve-list">{reserves.items.map(x=><div className="reserve-row" key={x.id}><div><strong>{x.name}</strong><span>{accountName(data,x.accountId)} · {categoryName(data,x.categoryId)} · {intervals[x.interval].label}</span></div><div><span>{formatMoney(x.annualNeed)} / Jahr</span><strong>{formatMoney(x.monthlyReserve)} / Monat</strong></div></div>)}</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 TextMetric({label,value,hint}){return <div className="metric text-metric"><span>{label}</span><strong>{value}</strong>{hint&&<small>{hint}</small>}</div>}
|
||||||
|
function CountMetric({label,value}){return <div className="metric"><span>{label}</span><strong>{Number(value||0).toLocaleString('de-DE')}</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 MovementsPage({data,onEdit,onDuplicate,onDelete,onAdd}){
|
||||||
|
const [section,setSection]=useState('incomes');
|
||||||
|
const tabs=[['incomes','Eingänge',data.incomes.length],['expenses','Ausgänge',data.expenses.length],['transfers','Transfers',data.transfers.length]];
|
||||||
|
return <div className="content-stack movements-page">
|
||||||
|
<section className="movement-toolbar"><div className="movement-tabs">{tabs.map(([id,label,count])=><button key={id} className={section===id?'active':''} onClick={()=>setSection(id)}><span>{label}</span><small>{count}</small></button>)}</div><button className="primary" onClick={()=>onAdd(section)}>+ {section==='incomes'?'Eingang':section==='expenses'?'Ausgang':'Transfer'}</button></section>
|
||||||
|
{section==='incomes'&&<Entries type="incomes" items={data.incomes} accounts={data.accounts} categories={data.categories} onEdit={x=>onEdit('incomes',x)} onDuplicate={x=>onDuplicate('incomes',x)} onDelete={onDelete}/>}
|
||||||
|
{section==='expenses'&&<Entries type="expenses" items={data.expenses} accounts={data.accounts} categories={data.categories} onEdit={x=>onEdit('expenses',x)} onDuplicate={x=>onDuplicate('expenses',x)} onDelete={onDelete}/>}
|
||||||
|
{section==='transfers'&&<Transfers data={data} onEdit={x=>onEdit('transfers',x)} onDuplicate={x=>onDuplicate('transfers',x)} onDelete={onDelete}/>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
function Entries({type,items,accounts,categories,onEdit,onDuplicate,onDelete}){
|
||||||
|
const accountNameLocal=id=>accounts.find(a=>a.id===id)?.name||'Unbekannt';
|
||||||
|
const categoryNameLocal=id=>categories.find(c=>c.id===id)?.name||'Ohne Kategorie';
|
||||||
|
const isIncome=type==='incomes';
|
||||||
|
const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt';
|
||||||
|
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}</h2><p>Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.</p></div></div>{items.length===0?<Empty text={`Noch keine ${isIncome?'Eingänge':'Ausgänge'} vorhanden.`}/>:<>
|
||||||
|
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Kategorie</th><th>Konto</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{items.map(x=><tr key={x.id}><td><strong>{x.name}</strong></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className={`number ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':'−'}{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div>
|
||||||
|
<div className="movement-mobile-list">{items.map(x=><article className="movement-card" key={x.id}>
|
||||||
|
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></div><strong className={isIncome?'good-text':'bad-text'}>{isIncome?'+':'−'}{formatMoney(x.amount)}</strong></div>
|
||||||
|
<div className="movement-card-grid">
|
||||||
|
<div><span>Konto</span><strong>{accountNameLocal(x.accountId)}</strong></div>
|
||||||
|
<div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div>
|
||||||
|
<div><span>Fälligkeit</span><strong>{dueText(x)}</strong></div>
|
||||||
|
<div><span>Ø / Monat</span><strong>{formatMoney(monthlyEquivalent(x))}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div className="movement-card-actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></div>
|
||||||
|
</article>)}</div>
|
||||||
|
</>}</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Transfers({data,onEdit,onDuplicate,onDelete}){
|
||||||
|
const accountNameLocal=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt';
|
||||||
|
const categoryNameLocal=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie';
|
||||||
|
const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt';
|
||||||
|
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>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 movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Kategorie</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><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.fromAccountId)}</td><td>{accountNameLocal(x.toAccountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className="number">{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></td></tr>)}</tbody></table></div>
|
||||||
|
<div className="movement-mobile-list">{data.transfers.map(x=><article className="movement-card transfer-card" key={x.id}>
|
||||||
|
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></div><strong>{formatMoney(x.amount)}</strong></div>
|
||||||
|
<div className="transfer-route"><div><span>Von</span><strong>{accountNameLocal(x.fromAccountId)}</strong></div><span className="route-arrow">→</span><div><span>Nach</span><strong>{accountNameLocal(x.toAccountId)}</strong></div></div>
|
||||||
|
<div className="movement-card-grid">
|
||||||
|
<div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div>
|
||||||
|
<div><span>Fälligkeit</span><strong>{dueText(x)}</strong></div>
|
||||||
|
<div><span>Ø / Monat</span><strong>{formatMoney(monthlyEquivalent(x))}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div className="movement-card-actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete('transfers',x.id)}>Löschen</button></div>
|
||||||
|
</article>)}</div>
|
||||||
|
</>}</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryDistribution({title,subtitle,stats,tone,data}){
|
||||||
|
function itemMeta(item){
|
||||||
|
if(item._statType==='transfer-in') return `Transfer rein · ${accountName(data,item.fromAccountId)} → ${accountName(data,item.toAccountId)}`;
|
||||||
|
if(item._statType==='transfer-out') return `Transfer raus · ${accountName(data,item.fromAccountId)} → ${accountName(data,item.toAccountId)}`;
|
||||||
|
if(item._statType==='transfer') return `Transfer · ${accountName(data,item.fromAccountId)} → ${accountName(data,item.toAccountId)}`;
|
||||||
|
return `${item._statType==='income'?'Eingang':'Ausgang'} · ${accountName(data,item.accountId)}`;
|
||||||
|
}
|
||||||
return <section className="panel category-stat-panel"><div className="section-head"><div><h2>{title}</h2><p>{subtitle}</p></div><strong className={`category-total ${tone==='income'?'good-text':tone==='expense'?'bad-text':''}`}>{formatMoney(stats.total)} / Monat</strong></div>
|
return <section className="panel category-stat-panel"><div className="section-head"><div><h2>{title}</h2><p>{subtitle}</p></div><strong className={`category-total ${tone==='income'?'good-text':tone==='expense'?'bad-text':''}`}>{formatMoney(stats.total)} / Monat</strong></div>
|
||||||
{stats.rows.length===0?<Empty text="Noch keine Werte für diese Auswahl vorhanden."/>:<div className="category-distribution">{stats.rows.map((row,index)=><div className="category-stat-row" key={row.id}><div className="category-stat-head"><div><span className="category-rank">{index+1}</span><strong>{row.name}</strong><small>{row.count} {row.count===1?'Position':'Positionen'}</small></div><div><strong>{formatMoney(row.amount)}</strong><span>{row.percent.toFixed(1).replace('.',',')} %</span></div></div><div className="category-bar"><span className={tone} style={{width:`${Math.max(row.percent,1)}%`}}/></div></div>)}</div>}
|
{stats.rows.length===0?<Empty text="Noch keine Werte für diese Auswahl vorhanden."/>:<div className="category-distribution">{stats.rows.map((row,index)=><details className="category-stat-row" key={row.id}><summary><div className="category-stat-head"><div><span className="category-rank">{index+1}</span><strong>{row.name}</strong><small>{row.count} {row.count===1?'Position':'Positionen'}</small></div><div><strong>{formatMoney(row.amount)}</strong><span>{row.percent.toFixed(1).replace('.',',')} %</span></div></div><div className="category-bar"><span className={tone} style={{width:`${Math.max(row.percent,1)}%`}}/></div></summary><div className="category-drilldown">{row.items.sort((a,b)=>b.statAmount-a.statAmount).map((item,i)=><div className="category-drill-row" key={`${item.id}-${i}`}><div><strong>{item.name}</strong><span>{itemMeta(item)} · {intervals[item.interval||'monthly'].label}</span></div><strong>{formatMoney(item.statAmount)} / Monat</strong></div>)}</div></details>)}</div>}
|
||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){
|
function StatisticsPage({data,onAddCategory,onEditCategory,onDeleteCategory}){
|
||||||
const [accountId,setAccountId]=useState('all');
|
const [accountId,setAccountId]=useState('all');
|
||||||
const stats=useMemo(()=>categoryStats(data,accountId),[data,accountId]);
|
const stats=useMemo(()=>categoryStats(data,accountId),[data,accountId]);
|
||||||
|
const totals=useMemo(()=>calc(data),[data]);
|
||||||
|
const kpis=useMemo(()=>dashboardKpis(data,totals),[data,totals]);
|
||||||
const selectedName=data.accounts.find(a=>a.id===accountId)?.name||'Alle Konten';
|
const selectedName=data.accounts.find(a=>a.id===accountId)?.name||'Alle Konten';
|
||||||
const usage=id=>data.incomes.filter(x=>x.categoryId===id).length+data.expenses.filter(x=>x.categoryId===id).length+data.transfers.filter(x=>x.categoryId===id).length;
|
const usage=id=>data.incomes.filter(x=>x.categoryId===id).length+data.expenses.filter(x=>x.categoryId===id).length+data.transfers.filter(x=>x.categoryId===id).length;
|
||||||
const relevantEntries=[...data.incomes.filter(x=>accountId==='all'||x.accountId===accountId),...data.expenses.filter(x=>accountId==='all'||x.accountId===accountId),...data.transfers.filter(x=>accountId==='all'||x.fromAccountId===accountId||x.toAccountId===accountId)];
|
const relevantEntries=[...data.incomes.filter(x=>accountId==='all'||x.accountId===accountId),...data.expenses.filter(x=>accountId==='all'||x.accountId===accountId),...data.transfers.filter(x=>accountId==='all'||x.fromAccountId===accountId||x.toAccountId===accountId)];
|
||||||
const uncategorized=relevantEntries.filter(x=>!x.categoryId).length;
|
const uncategorized=relevantEntries.filter(x=>!x.categoryId).length;
|
||||||
return <div className="content-stack stats-page">
|
return <div className="content-stack stats-page">
|
||||||
|
<section className="panel stats-overview-panel"><div className="section-head"><div><h2>Gesamtkennzahlen</h2><p>Die übergeordneten Kennzahlen liegen hier statt auf der Übersicht.</p></div></div><div className="stats-kpi-grid">
|
||||||
|
<Metric label="Gesamtsaldo / Monat" value={totals.balance} positive={totals.balance>=0} negative={totals.balance<0} featured/>
|
||||||
|
<TextMetric label="Fixkostenquote" value={`${kpis.fixedCostRatio.toFixed(1).replace('.',',')} %`} hint="Ausgänge ÷ Eingänge"/>
|
||||||
|
<Metric label="Fixkosten / Jahr" value={kpis.annualExpense} negative/>
|
||||||
|
<Metric label="Rücklage periodisch / Monat" value={kpis.reserveMonthly}/>
|
||||||
|
<TextMetric label="Teuerster Monat" value={kpis.expensive?.name||'–'} hint={kpis.expensive?formatMoney(kpis.expensive.displayExpense):'–'}/>
|
||||||
|
<TextMetric label="Günstigster Monat" value={kpis.cheap?.name||'–'} hint={kpis.cheap?formatMoney(kpis.cheap.displayExpense):'–'}/>
|
||||||
|
</div></section>
|
||||||
<section className="year-account-filter stats-filter"><div className="year-filter-copy"><span>Statistik für</span><strong>{selectedName}</strong></div><label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></section>
|
<section className="year-account-filter stats-filter"><div className="year-filter-copy"><span>Statistik für</span><strong>{selectedName}</strong></div><label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></section>
|
||||||
<section className="summary-grid stats-summary"><Metric label="Eingänge Ø / Monat" value={stats.incomes.total} positive/><Metric label="Ausgänge Ø / Monat" value={stats.expenses.total} negative/><CountMetric label="Kategorien" value={data.categories.length}/><CountMetric label="Ohne Kategorie" value={uncategorized}/></section>
|
<section className="summary-grid stats-summary"><Metric label="Eingänge Ø / Monat" value={stats.incomes.total} positive/><Metric label="Ausgänge Ø / Monat" value={stats.expenses.total} negative/><CountMetric label="Kategorien" value={data.categories.length}/><CountMetric label="Ohne Kategorie" value={uncategorized}/></section>
|
||||||
<CategoryDistribution title="Ausgaben nach Kategorien" subtitle={stats.transfersMerged?'Ausgaben dieses Kontos inklusive ausgehender Transfers, jeweils als Monatsdurchschnitt.':'Verteilung auf Basis des durchschnittlichen Monatswerts. Periodische Ausgaben werden heruntergerechnet.'} stats={stats.expenses} tone="expense"/>
|
<CategoryDistribution data={data} title="Ausgaben nach Kategorien" subtitle={stats.transfersMerged?'Ausgaben dieses Kontos inklusive ausgehender Transfers. Kategorie anklicken für die Einzelpositionen.':'Verteilung auf Basis des durchschnittlichen Monatswerts. Kategorie anklicken für die Einzelpositionen.'} stats={stats.expenses} tone="expense"/>
|
||||||
<CategoryDistribution title="Eingänge nach Kategorien" subtitle={stats.transfersMerged?'Eingänge dieses Kontos inklusive eingehender Transfers, jeweils als Monatsdurchschnitt.':'Verteilung der regelmäßigen Eingänge auf Kategorien, ebenfalls als Monatsdurchschnitt.'} stats={stats.incomes} tone="income"/>
|
<CategoryDistribution data={data} title="Eingänge nach Kategorien" subtitle={stats.transfersMerged?'Eingänge dieses Kontos inklusive eingehender Transfers. Kategorie anklicken für die Einzelpositionen.':'Verteilung der regelmäßigen Eingänge. Kategorie anklicken für die Einzelpositionen.'} stats={stats.incomes} tone="income"/>
|
||||||
{!stats.transfersMerged&&<CategoryDistribution title="Transfers nach Kategorien" subtitle="Interne Verschiebungen separat nach Kategorien. Sie verändern den Gesamtsaldo weiterhin nicht." stats={stats.transfers} tone="transfer"/>}
|
{!stats.transfersMerged&&<CategoryDistribution data={data} title="Transfers nach Kategorien" subtitle="Nur bei Alle Konten separat. Bei Einzelkonten fließen Transfers je nach Richtung in Ein- oder Ausgänge ein." stats={stats.transfers} tone="transfer"/>}
|
||||||
<section className="panel"><div className="section-head"><div><h2>Kategorien verwalten</h2><p>Kategorien gelten für Eingänge, Ausgänge und Transfers gemeinsam.</p></div><button className="secondary" onClick={onAddCategory}>+ Kategorie</button></div>{data.categories.length===0?<Empty text="Noch keine Kategorien angelegt."/>:<div className="category-manager-list">{data.categories.map(c=><div className="category-manager-row" key={c.id}><div><strong>{c.name}</strong><span>{usage(c.id)} {usage(c.id)===1?'zugeordneter Eintrag':'zugeordnete Einträge'}</span></div><div className="actions"><button onClick={()=>onEditCategory(c)}>Bearbeiten</button><button className="danger" onClick={()=>onDeleteCategory(c.id)}>Löschen</button></div></div>)}</div>}</section>
|
<section className="panel"><div className="section-head"><div><h2>Kategorien verwalten</h2><p>Kategorien gelten für Eingänge, Ausgänge und Transfers gemeinsam.</p></div><button className="secondary" onClick={onAddCategory}>+ Kategorie</button></div>{data.categories.length===0?<Empty text="Noch keine Kategorien angelegt."/>:<div className="category-manager-list">{data.categories.map(c=><div className="category-manager-row" key={c.id}><div><strong>{c.name}</strong><span>{usage(c.id)} {usage(c.id)===1?'zugeordneter Eintrag':'zugeordnete Einträge'}</span></div><div className="actions"><button onClick={()=>onEditCategory(c)}>Bearbeiten</button><button className="danger" onClick={()=>onDeleteCategory(c.id)}>Löschen</button></div></div>)}</div>}</section>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
@@ -264,83 +412,142 @@ function YearPage({data}){
|
|||||||
const forecast=useMemo(()=>annualForecast(data,accountId),[data,accountId]);
|
const forecast=useMemo(()=>annualForecast(data,accountId),[data,accountId]);
|
||||||
const selectedAccount=data.accounts.find(a=>a.id===accountId);
|
const selectedAccount=data.accounts.find(a=>a.id===accountId);
|
||||||
const selectedName=selectedAccount?.name||'Alle Konten';
|
const selectedName=selectedAccount?.name||'Alle Konten';
|
||||||
const kindLabel=x=>x.kind==='income'?'Eingang':x.kind==='expense'?'Ausgang':x.direction==='in'?'Transfer rein':x.direction==='out'?'Transfer raus':'Transfer';
|
const labels=forecast.isAll
|
||||||
|
? { income:'Einnahmen', expense:'Ausgaben', balance:'Überschuss', yearIncome:'Einnahmen / Jahr', yearExpense:'Ausgaben / Jahr', yearBalance:'Überschuss / Jahr' }
|
||||||
|
: { income:'Zuflüsse', expense:'Abflüsse', balance:'Nettofluss', yearIncome:'Zuflüsse / Jahr', yearExpense:'Abflüsse / Jahr', yearBalance:'Nettofluss / Jahr' };
|
||||||
|
const kindLabel=x=>x.kind==='income'?(forecast.isAll?'Eingang':'Echter Eingang'):x.kind==='expense'?(forecast.isAll?'Ausgang':'Echte Ausgabe'):x.direction==='in'?'Transfer rein':x.direction==='out'?'Transfer raus':'Transfer';
|
||||||
const kindSign=x=>x.kind==='income'||x.direction==='in'?'+':x.kind==='expense'||x.direction==='out'?'−':'';
|
const kindSign=x=>x.kind==='income'||x.direction==='in'?'+':x.kind==='expense'||x.direction==='out'?'−':'';
|
||||||
const kindTone=x=>x.kind==='income'||x.direction==='in'?'good-text':x.kind==='expense'||x.direction==='out'?'bad-text':'';
|
const kindTone=x=>x.kind==='income'||x.direction==='in'?'good-text':x.kind==='expense'||x.direction==='out'?'bad-text':'';
|
||||||
const chipLabel=x=>`${x.name} · ${intervals[x.interval||'monthly'].label} · ${kindLabel(x)} · ${formatMoney(x.amount)}`;
|
const chipLabel=x=>`${x.name} · ${intervals[x.interval||'monthly'].label} · ${kindLabel(x)} · ${formatMoney(x.amount)}`;
|
||||||
|
const maxExpense=Math.max(1,...forecast.rows.map(r=>r.displayExpense));
|
||||||
|
const maxRow=forecast.rows.reduce((a,b)=>b.displayExpense>a.displayExpense?b:a,forecast.rows[0]);
|
||||||
|
const yearBalance=forecast.yearIncome-forecast.yearExpense;
|
||||||
|
const detailRow=(x,i)=><div className="year-detail-row" key={`${x.id}-${x.direction||x.kind}-${i}`}><div className="year-detail-name"><span className={`year-kind ${x.kind}`}>{kindLabel(x)}</span><strong>{x.name}</strong></div><span className={`year-detail-amount ${kindTone(x)}`}>{kindSign(x)}{formatMoney(x.amount)}</span></div>;
|
||||||
|
|
||||||
return <div className="content-stack year-page">
|
return <div className="content-stack year-page">
|
||||||
<section className="year-account-filter">
|
<section className="year-account-filter"><div className="year-filter-copy"><span>Jahresansicht für</span><strong>{selectedName}</strong></div><label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></section>
|
||||||
<div className="year-filter-copy"><span>Jahresansicht für</span><strong>{selectedName}</strong></div>
|
|
||||||
<label><span>Konto auswählen</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="all">Alle Konten</option>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>
|
<section className="summary-grid year-summary">
|
||||||
|
<Metric label={labels.yearIncome} value={forecast.yearIncome} positive/>
|
||||||
|
<Metric label={labels.yearExpense} value={forecast.yearExpense} negative/>
|
||||||
|
<Metric label={labels.yearBalance} value={yearBalance} positive={yearBalance>=0} negative={yearBalance<0}/>
|
||||||
|
<CountMetric label="Nicht zugeordnet" value={forecast.unassigned.length}/>
|
||||||
</section>
|
</section>
|
||||||
<section className="summary-grid year-summary"><Metric label={forecast.isAll?'Einnahmen / Jahr':'Zuflüsse / Jahr'} value={forecast.yearIncome} positive/><Metric label={forecast.isAll?'Ausgaben / Jahr':'Abflüsse / Jahr'} value={forecast.yearExpense} negative/><Metric label="Saldo / Jahr" value={forecast.yearIncome-forecast.yearExpense} positive={forecast.yearIncome>=forecast.yearExpense} negative={forecast.yearIncome<forecast.yearExpense}/><CountMetric label="Nicht zugeordnet" value={forecast.unassigned.length}/></section>
|
|
||||||
<section className="panel year-forecast-panel"><div className="section-head"><div><h2>Jahresprognose</h2><p>{forecast.isAll?'Gesamt über alle Konten. Transfers bleiben saldoneutral.':`Prognose für ${selectedName}. Transfers werden als Zu- oder Abfluss dieses Kontos berücksichtigt.`}</p></div></div>
|
<section className="panel burden-profile"><div className="section-head"><div><h2>{forecast.isAll?'Belastungsprofil':'Abflussprofil'}</h2><p>{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.`}</p></div></div><div className="burden-bars">{forecast.rows.map(r=><div className={`burden-row ${r===maxRow?'peak':''}`} key={r.name}><span>{r.name.slice(0,3)}</span><div><i style={{width:`${Math.max(2,r.displayExpense/maxExpense*100)}%`}}/></div><strong>{formatMoney(r.displayExpense)}</strong></div>)}</div></section>
|
||||||
<div className="year-month-list">{forecast.rows.map(r=><details className="year-month-card" key={`${accountId}-${r.name}`}>
|
|
||||||
<summary>
|
<section className="panel year-forecast-panel"><div className="section-head"><div><h2>Jahresprognose</h2><p>{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.`}</p></div></div>
|
||||||
<div className="year-month-heading"><strong>{r.name}</strong><span>{r.items.length===0?'Keine Fälligkeiten':`${r.items.length} ${r.items.length===1?'Position':'Positionen'}`}</span></div>
|
<div className="year-month-list">{forecast.rows.map(r=>{
|
||||||
<div className="year-month-basics">
|
const inflows=r.items.filter(x=>x.kind==='income'||x.direction==='in');
|
||||||
<div><span>{forecast.isAll?'Eingänge':'Zuflüsse'}</span><strong className="good-text">+{formatMoney(r.displayIncome)}</strong></div>
|
const outflows=r.items.filter(x=>x.kind==='expense'||x.direction==='out');
|
||||||
<div><span>{forecast.isAll?'Ausgänge':'Abflüsse'}</span><strong className="bad-text">−{formatMoney(r.displayExpense)}</strong></div>
|
return <details className="year-month-card" key={`${accountId}-${r.name}`}><summary><div className="year-month-heading"><strong>{r.name}</strong><span>{r.items.length===0?'Keine Fälligkeiten':`${r.items.length} ${r.items.length===1?'Position':'Positionen'}`}</span></div><div className="year-month-basics"><div><span>{labels.income}</span><strong className="good-text">+{formatMoney(r.displayIncome)}</strong></div><div><span>{labels.expense}</span><strong className="bad-text">−{formatMoney(r.displayExpense)}</strong></div><div className="year-month-balance"><span>{labels.balance}</span><strong className={r.balance>=0?'good-text':'bad-text'}>{r.balance>=0?'+':''}{formatMoney(r.balance)}</strong></div></div><span className="accordion-chevron" aria-hidden="true">⌄</span></summary>
|
||||||
<div className="year-month-balance"><span>Saldo</span><strong className={r.balance>=0?'good-text':'bad-text'}>{r.balance>=0?'+':''}{formatMoney(r.balance)}</strong></div>
|
|
||||||
</div>
|
|
||||||
<span className="accordion-chevron" aria-hidden="true">⌄</span>
|
|
||||||
</summary>
|
|
||||||
<div className="year-month-details">
|
<div className="year-month-details">
|
||||||
{r.items.length===0?<div className="year-detail-empty">In diesem Monat sind keine einzelnen Fixpositionen fällig.</div>:r.items.map((x,i)=><div className="year-detail-row" key={`${x.id}-${x.direction||x.kind}-${i}`}>
|
{r.items.length===0?<div className="year-detail-empty">In diesem Monat sind keine einzelnen Fixpositionen fällig.</div>:forecast.isAll?<>{r.items.map(detailRow)}{(r.transferIn>0||r.transferOut>0)&&<div className="year-transfer-note">Interne Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie bleiben vollständig außerhalb von Einnahmen, Ausgaben und Überschuss.</div>}</>:<>
|
||||||
<div className="year-detail-name"><span className={`year-kind ${x.kind}`}>{kindLabel(x)}</span><strong>{x.name}</strong></div>
|
<div className="year-flow-breakdown">
|
||||||
<span className={`year-detail-amount ${kindTone(x)}`}>{kindSign(x)}{formatMoney(x.amount)}</span>
|
<div><span>Echte Eingänge</span><strong className="good-text">+{formatMoney(r.income)}</strong></div>
|
||||||
</div>)}
|
<div><span>Transfers rein</span><strong className="good-text">+{formatMoney(r.transferIn)}</strong></div>
|
||||||
{forecast.isAll&&(r.transferIn>0||r.transferOut>0)&&<div className="year-transfer-note">Transfers in diesem Monat: {formatMoney(r.transferOut)}. Sie verändern den Gesamtsaldo nicht.</div>}
|
<div><span>Echte Ausgaben</span><strong className="bad-text">−{formatMoney(r.expense)}</strong></div>
|
||||||
|
<div><span>Transfers raus</span><strong className="bad-text">−{formatMoney(r.transferOut)}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</details>)}</div>
|
{inflows.length>0&&<div className="year-detail-group"><div className="year-detail-group-title"><span>Zuflüsse</span><strong className="good-text">+{formatMoney(r.displayIncome)}</strong></div>{inflows.map(detailRow)}</div>}
|
||||||
|
{outflows.length>0&&<div className="year-detail-group"><div className="year-detail-group-title"><span>Abflüsse</span><strong className="bad-text">−{formatMoney(r.displayExpense)}</strong></div>{outflows.map(detailRow)}</div>}
|
||||||
|
<div className="year-transfer-note">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.</div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</details>;
|
||||||
|
})}</div>
|
||||||
</section>
|
</section>
|
||||||
{forecast.unassigned.length>0&&<section className="panel warning-panel"><div className="section-head"><div><h2>Periodische Positionen ohne Monat</h2><p>Diese Werte sind keinem konkreten Monat der Jahresprognose zugeordnet.</p></div></div><div className="due-items">{forecast.unassigned.map((x,i)=><span className={`due-chip ${x.kind}`} key={`${x.kind}-${x.id}-${x.direction||'neutral'}-${i}`}>{chipLabel(x)}</span>)}</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 sind keinem konkreten Monat der Jahresprognose zugeordnet.</p></div></div><div className="due-items">{forecast.unassigned.map((x,i)=><span className={`due-chip ${x.kind}`} key={`${x.kind}-${x.id}-${x.direction||'neutral'}-${i}`}>{chipLabel(x)}</span>)}</div></section>}
|
||||||
</div>;
|
</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.categories.length} Kategorien · {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 ScenariosPage({data,persist}){
|
||||||
|
const scenarios=data.scenarios||[];
|
||||||
|
const [selectedId,setSelectedId]=useState(scenarios[0]?.id||'');
|
||||||
|
const [adjustmentDialog,setAdjustmentDialog]=useState(null);
|
||||||
|
useEffect(()=>{if(selectedId&&!scenarios.some(s=>s.id===selectedId))setSelectedId(scenarios[0]?.id||'');if(!selectedId&&scenarios[0])setSelectedId(scenarios[0].id);},[scenarios,selectedId]);
|
||||||
|
const scenario=scenarios.find(s=>s.id===selectedId)||scenarios[0];
|
||||||
|
const baseTotals=useMemo(()=>calc(data),[data]);
|
||||||
|
const projectedData=useMemo(()=>scenarioData(data,scenario),[data,scenario]);
|
||||||
|
const projected=useMemo(()=>calc(projectedData),[projectedData]);
|
||||||
|
async function addScenario(){const name=prompt('Name des neuen Szenarios:','Was wäre wenn …');if(!name?.trim())return;const next={...data,scenarios:[...scenarios,{id:uid(),name:name.trim(),createdAt:new Date().toISOString(),adjustments:[]}]};const saved=await persist(next);setSelectedId(saved.scenarios[saved.scenarios.length-1]?.id||'');}
|
||||||
|
async function renameScenario(){if(!scenario)return;const name=prompt('Neuer Name:',scenario.name);if(!name?.trim())return;await persist({...data,scenarios:scenarios.map(s=>s.id===scenario.id?{...s,name:name.trim()}:s)});}
|
||||||
|
async function deleteScenario(){if(!scenario||!confirm(`Szenario „${scenario.name}“ wirklich löschen?`))return;await persist({...data,scenarios:scenarios.filter(s=>s.id!==scenario.id)});}
|
||||||
|
async function deleteAdjustment(id){if(!scenario||!confirm('Anpassung wirklich löschen?'))return;await persist({...data,scenarios:scenarios.map(s=>s.id===scenario.id?{...s,adjustments:(s.adjustments||[]).filter(a=>a.id!==id)}:s)});}
|
||||||
|
async function saveAdjustment(value){await persist({...data,scenarios:scenarios.map(s=>s.id===scenario.id?{...s,adjustments:adjustmentDialog?.item?.id?(s.adjustments||[]).map(a=>a.id===adjustmentDialog.item.id?value:a):[...(s.adjustments||[]),value]}:s)});setAdjustmentDialog(null);}
|
||||||
|
return <div className="content-stack scenario-page">
|
||||||
|
<section className="scenario-toolbar"><div><span>Szenario</span>{scenarios.length?<select value={scenario?.id||''} onChange={e=>setSelectedId(e.target.value)}>{scenarios.map(s=><option key={s.id} value={s.id}>{s.name}</option>)}</select>:<strong>Noch keines angelegt</strong>}</div><div className="button-row"><button className="primary" onClick={addScenario}>+ Szenario</button>{scenario&&<><button className="secondary" onClick={renameScenario}>Umbenennen</button><button className="secondary danger-button" onClick={deleteScenario}>Löschen</button></>}</div></section>
|
||||||
|
{!scenario?<section className="panel"><Empty text="Lege ein Szenario an. Es verwendet deinen aktuellen Plan als Basis und ergänzt nur die simulierten Änderungen."/></section>:<>
|
||||||
|
<section className="summary-grid scenario-summary"><Metric label="Aktuell frei / Monat" value={baseTotals.balance} positive={baseTotals.balance>=0} negative={baseTotals.balance<0}/><Metric label="Im Szenario frei / Monat" value={projected.balance} positive={projected.balance>=0} negative={projected.balance<0} featured/><Metric label="Veränderung" value={projected.balance-baseTotals.balance} positive={projected.balance-baseTotals.balance>=0} negative={projected.balance-baseTotals.balance<0}/><CountMetric label="Anpassungen" value={(scenario.adjustments||[]).length}/></section>
|
||||||
|
<section className="panel"><div className="section-head"><div><h2>Was-wäre-wenn-Anpassungen</h2><p>Der Basisplan bleibt unverändert. Hier simulierst du zusätzliche Eingänge, Ausgänge oder Transfers.</p></div><button className="secondary" onClick={()=>setAdjustmentDialog({})}>+ Anpassung</button></div>{(scenario.adjustments||[]).length===0?<Empty text="Noch keine Anpassungen in diesem Szenario."/>:<div className="scenario-adjustments">{scenario.adjustments.map(a=><div className="scenario-adjustment" key={a.id}><div><span className={`scenario-type ${a.type}`}>{a.type==='income'?'Eingang':a.type==='expense'?'Ausgang':'Transfer'}</span><strong>{a.name}</strong><small>{a.type==='transfer'?`${accountName(data,a.fromAccountId)} → ${accountName(data,a.toAccountId)}`:accountName(data,a.accountId)}</small></div><div><strong className={a.type==='income'?'good-text':a.type==='expense'?'bad-text':''}>{a.type==='income'?'+':a.type==='expense'?'−':''}{formatMoney(a.amount)} / Monat</strong><div className="actions"><button onClick={()=>setAdjustmentDialog({item:a})}>Bearbeiten</button><button className="danger" onClick={()=>deleteAdjustment(a.id)}>Löschen</button></div></div></div>)}</div>}</section>
|
||||||
|
<section className="panel"><div className="section-head"><div><h2>Auswirkung je Konto</h2><p>Vergleich des monatlichen Saldos zwischen Basisplan und Szenario.</p></div></div><div className="scenario-account-grid">{projected.accounts.map(p=>{const b=baseTotals.accounts.find(x=>x.account.id===p.account.id);const diff=p.balance-(b?.balance||0);return <div className="scenario-account" key={p.account.id}><strong>{p.account.name}</strong><div><span>Aktuell</span><b>{formatMoney(b?.balance||0)}</b></div><div><span>Szenario</span><b>{formatMoney(p.balance)}</b></div><div><span>Änderung</span><b className={diff>=0?'good-text':'bad-text'}>{diff>=0?'+':''}{formatMoney(diff)}</b></div></div>})}</div></section>
|
||||||
|
</>}
|
||||||
|
{adjustmentDialog&&scenario&&<ScenarioAdjustmentDialog data={data} item={adjustmentDialog.item} onClose={()=>setAdjustmentDialog(null)} onSave={saveAdjustment}/>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScenarioAdjustmentDialog({data,item,onClose,onSave}){
|
||||||
|
const [name,setName]=useState(item?.name||''); const [type,setType]=useState(item?.type||'expense'); 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 [error,setError]=useState('');
|
||||||
|
function submit(e){e.preventDefault();const numeric=parseAmount(amount);if(!name.trim())return setError('Bitte eine Bezeichnung eingeben.');if(!Number.isFinite(numeric)||numeric<0)return setError('Bitte einen gültigen Betrag eingeben.');if(type==='transfer'&&fromAccountId===toAccountId)return setError('Quell- und Zielkonto müssen verschieden sein.');const value={id:item?.id||uid(),name:name.trim(),type,amount:numeric};if(type==='transfer'){value.fromAccountId=fromAccountId;value.toAccountId=toAccountId;}else value.accountId=accountId;onSave(value);}
|
||||||
|
return <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}><div className="modal-head"><div><span className="eyebrow">SZENARIO</span><h2>{item?'Anpassung bearbeiten':'Anpassung hinzufügen'}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>{error&&<div className="alert">{error}</div>}<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder="z. B. Neue Wohnung"/></label><label><span>Art</span><select value={type} onChange={e=>setType(e.target.value)}><option value="income">Eingang</option><option value="expense">Ausgang</option><option value="transfer">Transfer</option></select></label><label><span>Betrag / Monat</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span>€</span></div></label>{type!=='transfer'?<label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>:<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button className="primary" type="submit">Speichern</button></div></form></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataPage({data,importFile}){
|
||||||
|
const summary=[
|
||||||
|
['Konten',data.accounts.length],
|
||||||
|
['Kategorien',data.categories.length],
|
||||||
|
['Eingänge',data.incomes.length],
|
||||||
|
['Ausgänge',data.expenses.length],
|
||||||
|
['Transfers',data.transfers.length],
|
||||||
|
['Szenarien',(data.scenarios||[]).length],
|
||||||
|
];
|
||||||
|
const json=JSON.stringify(data,null,2);
|
||||||
|
return <div className="content-stack data-page">
|
||||||
|
<section className="panel data-backup-panel"><div className="section-head"><div><h2>Daten sichern</h2><p>Alle Daten liegen in einer JSON-Datei. Vor jedem Speichern wird serverseitig automatisch eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row data-actions"><a className="primary button-link" href="/api/export">JSON herunterladen</a><label className="secondary button-link">JSON importieren<input type="file" accept="application/json,.json" hidden onChange={e=>e.target.files?.[0]&&importFile(e.target.files[0])}/></label></div></section>
|
||||||
|
<section className="panel data-status-panel"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>Kurzübersicht über den Inhalt deiner FixFin-Datei.</p></div></div>
|
||||||
|
<div className="data-summary-grid">{summary.map(([label,value])=><div className="data-summary-card" key={label}><span>{label}</span><strong>{Number(value).toLocaleString('de-DE')}</strong></div>)}</div>
|
||||||
|
<div className="data-json-desktop"><div className="data-subhead"><strong>JSON-Rohdaten</strong><span>Nur zur Kontrolle und Fehlersuche.</span></div><pre className="json-preview">{json}</pre></div>
|
||||||
|
<details className="data-json-mobile"><summary><span>JSON-Rohdaten anzeigen</span><small>Nur bei Bedarf öffnen</small></summary><pre className="json-preview">{json}</pre></details>
|
||||||
|
</section>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
function Empty({text}){return <div className="empty">{text}</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,item=dialog.item;
|
const type=dialog.type,item=dialog.item; const editing=Boolean(item?.id);
|
||||||
const [name,setName]=useState(item?.name||''),[amount,setAmount]=useState(item?.amount??''),[accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''),[fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''),[toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''),[interval,setInterval]=useState(item?.interval||'monthly'),[dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''),[categoryId,setCategoryId]=useState(item?.categoryId||''),[newCategoryName,setNewCategoryName]=useState(''),[localError,setLocalError]=useState('');
|
const [name,setName]=useState(item?.name||''),[amount,setAmount]=useState(item?.amount??''),[accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''),[fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''),[toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''),[interval,setInterval]=useState(item?.interval||'monthly'),[dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''),[categoryId,setCategoryId]=useState(item?.categoryId||''),[newCategoryName,setNewCategoryName]=useState(''),[localError,setLocalError]=useState('');
|
||||||
const labels={accounts:'Konto',categories:'Kategorie',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'};
|
const labels={accounts:'Konto',categories:'Kategorie',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'};
|
||||||
async function submit(e){
|
async function submit(e){
|
||||||
e.preventDefault();setLocalError('');
|
e.preventDefault();setLocalError(''); const numeric=parseAmount(amount);
|
||||||
const numeric=parseAmount(amount);
|
|
||||||
if(!name.trim())return setLocalError('Bitte eine Bezeichnung eingeben.');
|
if(!name.trim())return setLocalError('Bitte eine Bezeichnung eingeben.');
|
||||||
if(type!=='accounts'&&type!=='categories'&&(numeric<0||!Number.isFinite(numeric)))return setLocalError('Bitte einen gültigen Betrag eingeben.');
|
if(type!=='accounts'&&type!=='categories'&&(numeric<0||!Number.isFinite(numeric)))return setLocalError('Bitte einen gültigen Betrag eingeben.');
|
||||||
if(!['accounts','categories'].includes(type)&&data.accounts.length===0)return setLocalError('Bitte zuerst ein Konto anlegen.');
|
if(!['accounts','categories'].includes(type)&&data.accounts.length===0)return setLocalError('Bitte zuerst ein Konto anlegen.');
|
||||||
if(type==='transfers'&&fromAccountId===toAccountId)return setLocalError('Quell- und Zielkonto müssen verschieden sein.');
|
if(type==='transfers'&&fromAccountId===toAccountId)return setLocalError('Quell- und Zielkonto müssen verschieden sein.');
|
||||||
let next=structuredClone(data);const id=item?.id||uid();let value;
|
let next=structuredClone(data); const id=editing?item.id:uid(); let value;
|
||||||
if(type==='accounts') value={id,name:name.trim()};
|
if(type==='accounts') value={id,name:name.trim()};
|
||||||
else if(type==='categories') {
|
else if(type==='categories') { const duplicate=next.categories.some(c=>c.id!==item?.id&&c.name.trim().toLocaleLowerCase('de-DE')===name.trim().toLocaleLowerCase('de-DE')); if(duplicate)return setLocalError('Diese Kategorie existiert bereits.'); value={id,name:name.trim()}; }
|
||||||
const duplicate=next.categories.some(c=>c.id!==item?.id&&c.name.trim().toLocaleLowerCase('de-DE')===name.trim().toLocaleLowerCase('de-DE'));
|
else {
|
||||||
if(duplicate)return setLocalError('Diese Kategorie existiert bereits.');
|
|
||||||
value={id,name:name.trim()};
|
|
||||||
} else {
|
|
||||||
const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null)};
|
const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null)};
|
||||||
if(type==='incomes'||type==='expenses'||type==='transfers') {
|
|
||||||
let selectedCategoryId=categoryId||null;
|
let selectedCategoryId=categoryId||null;
|
||||||
if(categoryId==='__new__'){
|
if(categoryId==='__new__'){const categoryNameValue=newCategoryName.trim();if(!categoryNameValue)return setLocalError('Bitte einen Namen für die neue Kategorie eingeben.');const existing=next.categories.find(c=>c.name.trim().toLocaleLowerCase('de-DE')===categoryNameValue.toLocaleLowerCase('de-DE'));if(existing) selectedCategoryId=existing.id;else { selectedCategoryId=uid(); next.categories.push({id:selectedCategoryId,name:categoryNameValue}); }}
|
||||||
const categoryName=newCategoryName.trim();
|
if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId}; else value={...common,accountId,categoryId:selectedCategoryId};
|
||||||
if(!categoryName)return setLocalError('Bitte einen Namen für die neue Kategorie eingeben.');
|
|
||||||
const existing=next.categories.find(c=>c.name.trim().toLocaleLowerCase('de-DE')===categoryName.toLocaleLowerCase('de-DE'));
|
|
||||||
if(existing) selectedCategoryId=existing.id;
|
|
||||||
else { selectedCategoryId=uid(); next.categories.push({id:selectedCategoryId,name:categoryName}); }
|
|
||||||
}
|
}
|
||||||
if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId};
|
next[type]=editing?next[type].map(x=>x.id===item.id?value:x):[...next[type],value];
|
||||||
else value={...common,accountId,categoryId:selectedCategoryId};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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);}
|
try{await onSave(next);}catch(e){setLocalError(e.message);}
|
||||||
}
|
}
|
||||||
const simpleType=type==='accounts'||type==='categories';
|
const simpleType=type==='accounts'||type==='categories';
|
||||||
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':type==='categories'?'z. B. Wohnen':'z. B. Kfz-Versicherung'}/></label>
|
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">{dialog.duplicate?'DUPLIZIEREN':editing?'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':type==='categories'?'z. B. Wohnen':'z. B. Kfz-Versicherung'}/></label>
|
||||||
{!simpleType&&<><label><span>Betrag pro Intervall</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span>€</span></div></label><label><span>Intervall</span><select value={interval} onChange={e=>setInterval(e.target.value)}>{Object.entries(intervals).map(([key,x])=><option key={key} value={key}>{x.label}</option>)}</select></label>{interval!=='monthly'&&<label><span>{interval==='yearly'?'Monat der Fälligkeit (optional)':'Erster Fälligkeitsmonat (optional)'}</span><select value={dueMonth} onChange={e=>setDueMonth(e.target.value)}><option value="">Nicht festgelegt</option>{months.map((m,i)=><option key={m} value={i+1}>{m}</option>)}</select><small className="field-help">{interval==='quarterly'?'Ab diesem Monat alle 3 Monate.':interval==='semiannual'?'Ab diesem Monat alle 6 Monate.':'Für die Jahresprognose.'}</small></label>}</>}
|
{!simpleType&&<><label><span>Betrag pro Intervall</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span>€</span></div></label><label><span>Intervall</span><select value={interval} onChange={e=>setInterval(e.target.value)}>{Object.entries(intervals).map(([key,x])=><option key={key} value={key}>{x.label}</option>)}</select></label>{interval!=='monthly'&&<label><span>{interval==='yearly'?'Monat der Fälligkeit (optional)':'Erster Fälligkeitsmonat (optional)'}</span><select value={dueMonth} onChange={e=>setDueMonth(e.target.value)}><option value="">Nicht festgelegt</option>{months.map((m,i)=><option key={m} value={i+1}>{m}</option>)}</select><small className="field-help">{interval==='quarterly'?'Ab diesem Monat alle 3 Monate.':interval==='semiannual'?'Ab diesem Monat alle 6 Monate.':'Für die Jahresprognose.'}</small></label>}</>}
|
||||||
{(type==='incomes'||type==='expenses'||type==='transfers')&&<><label><span>Kategorie</span><select value={categoryId} onChange={e=>setCategoryId(e.target.value)}><option value="">Ohne Kategorie</option>{data.categories.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}<option value="__new__">+ Neue Kategorie …</option></select></label>{categoryId==='__new__'&&<label className="new-category-field"><span>Neue Kategorie</span><input value={newCategoryName} onChange={e=>setNewCategoryName(e.target.value)} placeholder="z. B. Sparen"/></label>}</>}
|
{(type==='incomes'||type==='expenses'||type==='transfers')&&<><label><span>Kategorie</span><select value={categoryId} onChange={e=>setCategoryId(e.target.value)}><option value="">Ohne Kategorie</option>{data.categories.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}<option value="__new__">+ Neue Kategorie …</option></select></label>{categoryId==='__new__'&&<label className="new-category-field"><span>Neue Kategorie</span><input value={newCategoryName} onChange={e=>setNewCategoryName(e.target.value)} placeholder="z. B. Sparen"/></label>}</>}
|
||||||
{(type==='incomes'||type==='expenses')&&<label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>}
|
{(type==='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>;
|
{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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load',()=>{
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(error=>console.warn('Service Worker konnte nicht registriert werden:',error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(<App/>);
|
createRoot(document.getElementById('root')).render(<App/>);
|
||||||
|
|||||||
+480
@@ -383,3 +383,483 @@ code { color:#b8c3ff; }
|
|||||||
.stats-summary { grid-template-columns:1fr 1fr; }
|
.stats-summary { grid-template-columns:1fr 1fr; }
|
||||||
.category-stat-head > div:last-child { flex-direction:column; align-items:flex-end; gap:0; }
|
.category-stat-head > div:last-child { flex-direction:column; align-items:flex-end; gap:0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v3.9: Finanzcockpit, Bewegungen, Drilldowns, Szenarien, Belastungsprofil */
|
||||||
|
.cockpit-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:14px; }
|
||||||
|
.text-metric small { color:var(--muted); font-size:11px; margin-top:5px; }
|
||||||
|
.funding-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
|
||||||
|
.funding-card { background:#0d131c; border:1px solid #29364a; border-radius:14px; padding:15px; min-width:0; }
|
||||||
|
.funding-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; padding-bottom:12px; border-bottom:1px solid #202a39; }
|
||||||
|
.funding-head strong:first-child { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.funding-head span { flex:0 0 auto; font-size:11px; font-weight:850; }
|
||||||
|
.funding-numbers { display:grid; gap:8px; margin-top:12px; }
|
||||||
|
.funding-numbers div { display:flex; justify-content:space-between; gap:10px; align-items:baseline; }
|
||||||
|
.funding-numbers span { color:var(--muted); font-size:10px; }
|
||||||
|
.funding-numbers strong { font-size:12px; white-space:nowrap; }
|
||||||
|
.reserve-total { display:flex; flex-direction:column; align-items:flex-end; gap:2px; white-space:nowrap; }
|
||||||
|
.reserve-total span { color:var(--muted); font-size:10px; }
|
||||||
|
.reserve-total strong { font-size:16px; }
|
||||||
|
.reserve-list { display:grid; gap:8px; }
|
||||||
|
.reserve-row { display:flex; justify-content:space-between; align-items:center; gap:16px; padding:11px 12px; background:#0d131c; border:1px solid #263244; border-radius:11px; }
|
||||||
|
.reserve-row > div:first-child { min-width:0; }
|
||||||
|
.reserve-row > div:first-child strong { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px; }
|
||||||
|
.reserve-row > div:first-child span { display:block; color:var(--muted); font-size:9px; margin-top:3px; }
|
||||||
|
.reserve-row > div:last-child { flex:0 0 auto; text-align:right; }
|
||||||
|
.reserve-row > div:last-child span { display:block; color:var(--muted); font-size:9px; }
|
||||||
|
.reserve-row > div:last-child strong { font-size:12px; }
|
||||||
|
|
||||||
|
.movement-toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px; background:#101722; border:1px solid var(--line); border-radius:15px; padding:8px; }
|
||||||
|
.movement-tabs { display:flex; gap:5px; min-width:0; }
|
||||||
|
.movement-tabs button { display:flex; align-items:center; gap:7px; border:0; border-radius:10px; background:transparent; color:var(--muted); padding:9px 12px; font-weight:750; }
|
||||||
|
.movement-tabs button small { min-width:20px; padding:2px 6px; border-radius:999px; background:#202a39; color:#9aa8ba; font-size:9px; }
|
||||||
|
.movement-tabs button.active { background:#202a39; color:var(--text); }
|
||||||
|
.movement-tabs button.active small { background:#34415a; color:#dfe5ee; }
|
||||||
|
|
||||||
|
.category-stat-row { border-radius:11px; }
|
||||||
|
.category-stat-row > summary { list-style:none; cursor:pointer; padding:3px 0; }
|
||||||
|
.category-stat-row > summary::-webkit-details-marker { display:none; }
|
||||||
|
.category-stat-row[open] { background:#0c121b; border:1px solid #263244; padding:10px; }
|
||||||
|
.category-stat-row[open] > summary { padding:0; }
|
||||||
|
.category-drilldown { display:grid; gap:6px; margin-top:10px; padding-top:9px; border-top:1px solid #202b3b; }
|
||||||
|
.category-drill-row { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:7px 8px; border-radius:8px; background:#111925; }
|
||||||
|
.category-drill-row > div { min-width:0; }
|
||||||
|
.category-drill-row > div strong { display:block; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.category-drill-row > div span { display:block; color:var(--muted); font-size:8px; margin-top:2px; }
|
||||||
|
.category-drill-row > strong { flex:0 0 auto; font-size:10px; white-space:nowrap; }
|
||||||
|
|
||||||
|
.burden-bars { display:grid; gap:8px; }
|
||||||
|
.burden-row { display:grid; grid-template-columns:34px minmax(0,1fr) 105px; align-items:center; gap:9px; }
|
||||||
|
.burden-row > span { color:var(--muted); font-size:9px; font-weight:800; text-transform:uppercase; }
|
||||||
|
.burden-row > div { height:11px; border-radius:999px; background:#0a1018; border:1px solid #202b3b; overflow:hidden; }
|
||||||
|
.burden-row > div i { display:block; height:100%; border-radius:inherit; background:linear-gradient(90deg,#5869b7,#8b9cff); }
|
||||||
|
.burden-row.peak > div i { background:linear-gradient(90deg,#c35c68,#ff7c86); }
|
||||||
|
.burden-row.peak > span,.burden-row.peak > strong { color:#ff9ba3; }
|
||||||
|
.burden-row > strong { text-align:right; font-size:10px; white-space:nowrap; }
|
||||||
|
|
||||||
|
.scenario-toolbar { display:flex; align-items:end; justify-content:space-between; gap:14px; background:#101722; border:1px solid var(--line); border-radius:15px; padding:13px; }
|
||||||
|
.scenario-toolbar > div:first-child { display:grid; gap:5px; min-width:min(420px,55%); }
|
||||||
|
.scenario-toolbar > div:first-child > span { color:var(--muted); font-size:9px; text-transform:uppercase; font-weight:850; letter-spacing:.06em; }
|
||||||
|
.scenario-toolbar select,.year-account-filter select { width:100%; background:#0b111a; color:white; border:1px solid #2a374a; border-radius:10px; padding:10px 11px; }
|
||||||
|
.danger-button { color:#ff929a; }
|
||||||
|
.scenario-adjustments { display:grid; gap:8px; }
|
||||||
|
.scenario-adjustment { display:flex; align-items:center; justify-content:space-between; gap:14px; padding:12px; border:1px solid #29364a; background:#0d131c; border-radius:12px; }
|
||||||
|
.scenario-adjustment > div:first-child { min-width:0; display:grid; grid-template-columns:auto minmax(0,1fr); align-items:center; column-gap:8px; }
|
||||||
|
.scenario-adjustment > div:first-child strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.scenario-adjustment > div:first-child small { grid-column:2; color:var(--muted); font-size:9px; }
|
||||||
|
.scenario-adjustment > div:last-child { flex:0 0 auto; text-align:right; }
|
||||||
|
.scenario-adjustment > div:last-child > strong { font-size:12px; }
|
||||||
|
.scenario-type { grid-row:1 / span 2; padding:4px 7px; border-radius:999px; font-size:8px; font-weight:850; text-transform:uppercase; background:#202b3b; }
|
||||||
|
.scenario-type.income { color:var(--good); background:#15392f; }
|
||||||
|
.scenario-type.expense { color:var(--bad); background:#401d25; }
|
||||||
|
.scenario-type.transfer { color:#adb9ff; background:#1c2340; }
|
||||||
|
.scenario-account-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; }
|
||||||
|
.scenario-account { border:1px solid #29364a; background:#0d131c; border-radius:12px; padding:12px; }
|
||||||
|
.scenario-account > strong { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin-bottom:9px; }
|
||||||
|
.scenario-account div { display:flex; justify-content:space-between; gap:9px; padding:4px 0; }
|
||||||
|
.scenario-account div span { color:var(--muted); font-size:9px; }
|
||||||
|
.scenario-account div b { font-size:10px; }
|
||||||
|
|
||||||
|
@media (max-width:1050px) {
|
||||||
|
.cockpit-grid,.funding-grid,.scenario-account-grid { grid-template-columns:1fr 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width:760px) {
|
||||||
|
.mobile-nav { grid-template-columns:repeat(7,minmax(50px,1fr)); }
|
||||||
|
.movement-toolbar { align-items:stretch; flex-direction:column; }
|
||||||
|
.movement-tabs { display:grid; grid-template-columns:repeat(3,1fr); }
|
||||||
|
.movement-tabs button { justify-content:center; padding:9px 5px; font-size:11px; }
|
||||||
|
.movement-toolbar > .primary { width:100%; }
|
||||||
|
.cockpit-grid,.funding-grid,.scenario-account-grid { grid-template-columns:1fr 1fr; gap:8px; }
|
||||||
|
.scenario-toolbar { align-items:stretch; flex-direction:column; }
|
||||||
|
.scenario-toolbar > div:first-child { min-width:0; width:100%; }
|
||||||
|
.scenario-adjustment { align-items:flex-start; flex-direction:column; }
|
||||||
|
.scenario-adjustment > div:last-child { width:100%; text-align:left; }
|
||||||
|
.scenario-adjustment .actions { margin-top:5px; text-align:left; }
|
||||||
|
.scenario-adjustment .actions button { margin-left:0; margin-right:5px; }
|
||||||
|
.reserve-panel .section-head { align-items:flex-start; }
|
||||||
|
.reserve-row { gap:8px; }
|
||||||
|
.burden-row { grid-template-columns:28px minmax(0,1fr) 86px; gap:6px; }
|
||||||
|
.burden-row > strong { font-size:9px; }
|
||||||
|
}
|
||||||
|
@media (max-width:430px) {
|
||||||
|
.cockpit-grid,.funding-grid,.scenario-account-grid { grid-template-columns:1fr; }
|
||||||
|
.reserve-panel .section-head { flex-direction:column; }
|
||||||
|
.reserve-total { align-items:flex-start; }
|
||||||
|
.reserve-row { align-items:flex-start; flex-direction:column; }
|
||||||
|
.reserve-row > div:last-child { text-align:left; }
|
||||||
|
.category-drill-row { align-items:flex-start; flex-direction:column; }
|
||||||
|
.category-drill-row > strong { align-self:flex-end; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* v3.10: mobile Bewegungen + Daten, Planung als eigener Bereich */
|
||||||
|
.movement-mobile-list { display:none; }
|
||||||
|
.data-summary-grid { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:10px; }
|
||||||
|
.data-summary-card { min-width:0; background:#0d131c; border:1px solid #29364a; border-radius:12px; padding:13px; }
|
||||||
|
.data-summary-card span { display:block; color:var(--muted); font-size:9px; font-weight:800; text-transform:uppercase; letter-spacing:.055em; }
|
||||||
|
.data-summary-card strong { display:block; margin-top:5px; font-size:22px; letter-spacing:-.035em; }
|
||||||
|
.data-subhead { display:flex; align-items:end; justify-content:space-between; gap:12px; margin:18px 0 8px; }
|
||||||
|
.data-subhead strong { font-size:13px; }
|
||||||
|
.data-subhead span { color:var(--muted); font-size:9px; }
|
||||||
|
.data-json-mobile { display:none; }
|
||||||
|
.planning-summary { grid-template-columns:repeat(4,minmax(0,1fr)); }
|
||||||
|
|
||||||
|
@media (max-width:1050px) {
|
||||||
|
.data-summary-grid { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||||
|
.planning-summary { grid-template-columns:1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width:760px) {
|
||||||
|
main { min-width:0; overflow-x:hidden; padding:18px 12px 92px; }
|
||||||
|
.topbar { margin-bottom:16px; }
|
||||||
|
.topbar h1 { font-size:24px; }
|
||||||
|
.topbar p { max-width:none; font-size:11px; }
|
||||||
|
.content-stack { gap:12px; }
|
||||||
|
.panel { min-width:0; padding:13px; }
|
||||||
|
|
||||||
|
.mobile-nav {
|
||||||
|
grid-template-columns:repeat(8,minmax(0,1fr));
|
||||||
|
width:100%;
|
||||||
|
max-width:100%;
|
||||||
|
overflow-x:hidden;
|
||||||
|
padding-left:3px;
|
||||||
|
padding-right:3px;
|
||||||
|
}
|
||||||
|
.mobile-nav button {
|
||||||
|
min-width:0;
|
||||||
|
padding:5px 0;
|
||||||
|
font-size:8px;
|
||||||
|
gap:2px;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.mobile-nav button span {
|
||||||
|
width:100%;
|
||||||
|
overflow:hidden;
|
||||||
|
text-overflow:ellipsis;
|
||||||
|
white-space:nowrap;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
.mobile-nav svg { width:17px; height:17px; }
|
||||||
|
|
||||||
|
.movements-page .movement-toolbar {
|
||||||
|
padding:6px;
|
||||||
|
gap:7px;
|
||||||
|
border-radius:13px;
|
||||||
|
}
|
||||||
|
.movement-tabs { gap:3px; }
|
||||||
|
.movement-tabs button {
|
||||||
|
min-width:0;
|
||||||
|
padding:9px 3px;
|
||||||
|
font-size:10px;
|
||||||
|
gap:4px;
|
||||||
|
}
|
||||||
|
.movement-tabs button small {
|
||||||
|
min-width:17px;
|
||||||
|
padding:2px 4px;
|
||||||
|
font-size:8px;
|
||||||
|
}
|
||||||
|
.movement-toolbar > .primary {
|
||||||
|
min-height:42px;
|
||||||
|
border-radius:10px;
|
||||||
|
}
|
||||||
|
.movement-list-panel .section-head {
|
||||||
|
margin-bottom:12px;
|
||||||
|
}
|
||||||
|
.movement-list-panel .section-head h2 { font-size:17px; }
|
||||||
|
.movement-list-panel .section-head p { font-size:10px; line-height:1.4; }
|
||||||
|
.movement-desktop-table { display:none; }
|
||||||
|
.movement-mobile-list { display:grid; gap:9px; }
|
||||||
|
.movement-card {
|
||||||
|
min-width:0;
|
||||||
|
border:1px solid #29364a;
|
||||||
|
background:#0d131c;
|
||||||
|
border-radius:13px;
|
||||||
|
padding:12px;
|
||||||
|
}
|
||||||
|
.movement-card-head {
|
||||||
|
display:flex;
|
||||||
|
justify-content:space-between;
|
||||||
|
align-items:flex-start;
|
||||||
|
gap:10px;
|
||||||
|
padding-bottom:10px;
|
||||||
|
border-bottom:1px solid #202a39;
|
||||||
|
}
|
||||||
|
.movement-card-head > div {
|
||||||
|
min-width:0;
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
align-items:flex-start;
|
||||||
|
gap:6px;
|
||||||
|
}
|
||||||
|
.movement-card-head > div > strong {
|
||||||
|
max-width:100%;
|
||||||
|
overflow:hidden;
|
||||||
|
text-overflow:ellipsis;
|
||||||
|
white-space:nowrap;
|
||||||
|
font-size:14px;
|
||||||
|
}
|
||||||
|
.movement-card-head > strong {
|
||||||
|
flex:0 0 auto;
|
||||||
|
font-size:15px;
|
||||||
|
white-space:nowrap;
|
||||||
|
}
|
||||||
|
.movement-card .category-badge { font-size:8px; padding:3px 6px; }
|
||||||
|
.movement-card-grid {
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:1fr 1fr;
|
||||||
|
gap:9px 12px;
|
||||||
|
padding:11px 0;
|
||||||
|
}
|
||||||
|
.movement-card-grid > div { min-width:0; }
|
||||||
|
.movement-card-grid span,
|
||||||
|
.transfer-route span {
|
||||||
|
display:block;
|
||||||
|
color:#718096;
|
||||||
|
font-size:8px;
|
||||||
|
text-transform:uppercase;
|
||||||
|
letter-spacing:.055em;
|
||||||
|
font-weight:800;
|
||||||
|
margin-bottom:3px;
|
||||||
|
}
|
||||||
|
.movement-card-grid strong,
|
||||||
|
.transfer-route strong {
|
||||||
|
display:block;
|
||||||
|
font-size:10px;
|
||||||
|
overflow:hidden;
|
||||||
|
text-overflow:ellipsis;
|
||||||
|
white-space:nowrap;
|
||||||
|
}
|
||||||
|
.movement-card-actions {
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||||
|
gap:6px;
|
||||||
|
padding-top:10px;
|
||||||
|
border-top:1px solid #202a39;
|
||||||
|
}
|
||||||
|
.movement-card-actions button {
|
||||||
|
min-width:0;
|
||||||
|
border:1px solid #2a374a;
|
||||||
|
background:#171f2d;
|
||||||
|
color:#d8e0ea;
|
||||||
|
border-radius:9px;
|
||||||
|
padding:8px 3px;
|
||||||
|
font-size:9px;
|
||||||
|
font-weight:750;
|
||||||
|
}
|
||||||
|
.movement-card-actions button.danger { color:#ff929a; }
|
||||||
|
.transfer-route {
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:minmax(0,1fr) 20px minmax(0,1fr);
|
||||||
|
align-items:center;
|
||||||
|
gap:6px;
|
||||||
|
padding:11px 0 2px;
|
||||||
|
}
|
||||||
|
.transfer-route > div { min-width:0; }
|
||||||
|
.route-arrow {
|
||||||
|
color:#8b9cff !important;
|
||||||
|
font-size:17px !important;
|
||||||
|
text-align:center;
|
||||||
|
margin:0 !important;
|
||||||
|
}
|
||||||
|
.transfer-card .movement-card-grid {
|
||||||
|
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-page .section-head { margin-bottom:12px; }
|
||||||
|
.data-page .section-head h2 { font-size:17px; }
|
||||||
|
.data-page .section-head p { font-size:10px; line-height:1.45; }
|
||||||
|
.data-actions {
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:1fr;
|
||||||
|
gap:8px;
|
||||||
|
}
|
||||||
|
.data-actions .button-link {
|
||||||
|
width:100%;
|
||||||
|
min-height:44px;
|
||||||
|
justify-content:center;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
.data-summary-grid {
|
||||||
|
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||||
|
gap:7px;
|
||||||
|
}
|
||||||
|
.data-summary-card {
|
||||||
|
padding:10px;
|
||||||
|
border-radius:10px;
|
||||||
|
}
|
||||||
|
.data-summary-card span { font-size:7px; }
|
||||||
|
.data-summary-card strong { font-size:19px; }
|
||||||
|
.data-json-desktop { display:none; }
|
||||||
|
.data-json-mobile {
|
||||||
|
display:block;
|
||||||
|
margin-top:12px;
|
||||||
|
border:1px solid #29364a;
|
||||||
|
background:#0d131c;
|
||||||
|
border-radius:11px;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.data-json-mobile > summary {
|
||||||
|
list-style:none;
|
||||||
|
cursor:pointer;
|
||||||
|
padding:12px;
|
||||||
|
}
|
||||||
|
.data-json-mobile > summary::-webkit-details-marker { display:none; }
|
||||||
|
.data-json-mobile > summary span {
|
||||||
|
display:block;
|
||||||
|
font-size:11px;
|
||||||
|
font-weight:800;
|
||||||
|
}
|
||||||
|
.data-json-mobile > summary small {
|
||||||
|
display:block;
|
||||||
|
color:var(--muted);
|
||||||
|
font-size:8px;
|
||||||
|
margin-top:2px;
|
||||||
|
}
|
||||||
|
.data-json-mobile .json-preview {
|
||||||
|
max-height:50vh;
|
||||||
|
border:0;
|
||||||
|
border-top:1px solid #202a39;
|
||||||
|
border-radius:0;
|
||||||
|
font-size:9px;
|
||||||
|
padding:10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-summary { grid-template-columns:1fr 1fr; gap:8px; }
|
||||||
|
.planning-summary .metric { min-height:84px; padding:12px; }
|
||||||
|
.planning-summary .metric strong { font-size:18px; }
|
||||||
|
.planning-page .funding-grid { grid-template-columns:1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width:430px) {
|
||||||
|
.data-summary-grid { grid-template-columns:1fr 1fr; }
|
||||||
|
.planning-summary { grid-template-columns:1fr 1fr; }
|
||||||
|
.transfer-card .movement-card-grid { grid-template-columns:1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* v3.12: reduziertes Dashboard + persistenter Mehrfach-Kontofilter + PWA polish */
|
||||||
|
.dashboard-account-filter {
|
||||||
|
border:1px solid #29364a;
|
||||||
|
background:#0f1621;
|
||||||
|
border-radius:14px;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.dashboard-account-filter > summary {
|
||||||
|
list-style:none;
|
||||||
|
cursor:pointer;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:space-between;
|
||||||
|
gap:12px;
|
||||||
|
padding:12px 14px;
|
||||||
|
user-select:none;
|
||||||
|
}
|
||||||
|
.dashboard-account-filter > summary::-webkit-details-marker { display:none; }
|
||||||
|
.dashboard-account-filter > summary > div { min-width:0; }
|
||||||
|
.dashboard-account-filter > summary span:first-child {
|
||||||
|
display:block;
|
||||||
|
color:var(--muted);
|
||||||
|
font-size:9px;
|
||||||
|
font-weight:850;
|
||||||
|
text-transform:uppercase;
|
||||||
|
letter-spacing:.075em;
|
||||||
|
}
|
||||||
|
.dashboard-account-filter > summary strong {
|
||||||
|
display:block;
|
||||||
|
margin-top:2px;
|
||||||
|
font-size:14px;
|
||||||
|
overflow:hidden;
|
||||||
|
text-overflow:ellipsis;
|
||||||
|
white-space:nowrap;
|
||||||
|
}
|
||||||
|
.dashboard-account-filter[open] > summary .accordion-chevron { transform:rotate(180deg); }
|
||||||
|
.dashboard-filter-options {
|
||||||
|
display:flex;
|
||||||
|
flex-wrap:wrap;
|
||||||
|
gap:7px;
|
||||||
|
padding:0 14px 14px;
|
||||||
|
border-top:1px solid #202a39;
|
||||||
|
padding-top:12px;
|
||||||
|
}
|
||||||
|
.filter-chip {
|
||||||
|
border:1px solid #2e3b50;
|
||||||
|
background:#121a26;
|
||||||
|
color:#98a6b8;
|
||||||
|
border-radius:999px;
|
||||||
|
padding:7px 10px;
|
||||||
|
font-size:10px;
|
||||||
|
font-weight:800;
|
||||||
|
}
|
||||||
|
.filter-chip.active {
|
||||||
|
color:#e9edff;
|
||||||
|
background:#202a45;
|
||||||
|
border-color:#5a6cc7;
|
||||||
|
box-shadow:inset 0 0 0 1px #7587e533;
|
||||||
|
}
|
||||||
|
.dashboard-balances .section-head { align-items:flex-end; }
|
||||||
|
.dashboard-selected-total { flex:0 0 auto; text-align:right; }
|
||||||
|
.dashboard-selected-total span {
|
||||||
|
display:block;
|
||||||
|
color:var(--muted);
|
||||||
|
font-size:9px;
|
||||||
|
font-weight:800;
|
||||||
|
text-transform:uppercase;
|
||||||
|
letter-spacing:.06em;
|
||||||
|
margin-bottom:2px;
|
||||||
|
}
|
||||||
|
.dashboard-selected-total strong { font-size:23px; white-space:nowrap; letter-spacing:-.035em; }
|
||||||
|
.dashboard-toggle { margin-top:2px; }
|
||||||
|
.stats-kpi-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; }
|
||||||
|
.stats-kpi-grid .metric { min-height:98px; padding:15px; }
|
||||||
|
.stats-kpi-grid .metric strong { font-size:21px; }
|
||||||
|
|
||||||
|
@media (display-mode: standalone) {
|
||||||
|
body { overscroll-behavior-y:none; }
|
||||||
|
.topbar { padding-top:max(0px, env(safe-area-inset-top)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width:900px) {
|
||||||
|
.stats-kpi-grid { grid-template-columns:1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width:760px) {
|
||||||
|
.dashboard-page { gap:10px; }
|
||||||
|
.dashboard-account-filter > summary { padding:10px 11px; }
|
||||||
|
.dashboard-filter-options { padding:10px 11px 11px; gap:6px; }
|
||||||
|
.filter-chip { padding:7px 9px; font-size:9px; }
|
||||||
|
.dashboard-balances { padding:12px; }
|
||||||
|
.dashboard-balances .section-head { margin-bottom:10px; }
|
||||||
|
.dashboard-balances .section-head h2 { font-size:17px; }
|
||||||
|
.dashboard-balances .section-head p { font-size:9px; }
|
||||||
|
.dashboard-selected-total span { font-size:7px; }
|
||||||
|
.dashboard-selected-total strong { font-size:18px; }
|
||||||
|
.dashboard-toggle { padding:12px; }
|
||||||
|
.dashboard-toggle strong { font-size:11px; }
|
||||||
|
.dashboard-toggle span:not(.switch span) { font-size:9px; line-height:1.35; }
|
||||||
|
.stats-kpi-grid { grid-template-columns:1fr 1fr; gap:8px; }
|
||||||
|
.stats-kpi-grid .metric { min-height:82px; padding:11px; }
|
||||||
|
.stats-kpi-grid .metric span { font-size:9px; }
|
||||||
|
.stats-kpi-grid .metric strong { font-size:17px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width:430px) {
|
||||||
|
.dashboard-balances .section-head { align-items:flex-start; }
|
||||||
|
.dashboard-selected-total { text-align:left; }
|
||||||
|
.stats-kpi-grid { grid-template-columns:1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* v3.14: Jahresansicht trennt Wirtschaftssicht und Konto-Liquidität */
|
||||||
|
.year-flow-breakdown { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; padding:10px 0 12px; border-bottom:1px solid #202a39; }
|
||||||
|
.year-flow-breakdown > div { min-width:0; background:#0b111a; border:1px solid #202c3c; border-radius:10px; padding:9px 10px; }
|
||||||
|
.year-flow-breakdown span { display:block; color:#7f8da1; font-size:8px; font-weight:800; text-transform:uppercase; letter-spacing:.05em; margin-bottom:4px; }
|
||||||
|
.year-flow-breakdown strong { display:block; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.year-detail-group { padding-top:10px; }
|
||||||
|
.year-detail-group + .year-detail-group { margin-top:3px; border-top:1px dashed #2a3546; }
|
||||||
|
.year-detail-group-title { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:2px 0 4px; }
|
||||||
|
.year-detail-group-title > span { color:#a9b5c6; font-size:9px; font-weight:850; text-transform:uppercase; letter-spacing:.065em; }
|
||||||
|
.year-detail-group-title > strong { font-size:11px; white-space:nowrap; }
|
||||||
|
|
||||||
|
@media (max-width:760px) {
|
||||||
|
.year-flow-breakdown { grid-template-columns:1fr 1fr; gap:6px; }
|
||||||
|
.year-flow-breakdown > div { padding:8px; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user