push
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.local-data
|
||||
*.log
|
||||
finance.json
|
||||
finance.json.bak
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.local-data/
|
||||
*.log
|
||||
.env
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
DATA_FILE=/app/data/finance.json
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev && npm cache clean --force
|
||||
COPY server ./server
|
||||
COPY --from=build /app/dist ./dist
|
||||
RUN mkdir -p /app/data && chown -R node:node /app
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server/index.js"]
|
||||
@@ -0,0 +1,87 @@
|
||||
# FixFin
|
||||
|
||||
Kleine responsive Webapp für **monatliche Fixplanung ohne Buchungsverlauf und ohne Kontostände**.
|
||||
|
||||
## Datenmodell
|
||||
|
||||
- Konten: nur Name / Zuordnung
|
||||
- fixe Eingänge: Bezeichnung, Betrag pro Monat, Konto
|
||||
- fixe Ausgänge: Bezeichnung, Betrag pro Monat, Konto
|
||||
- fixe Transfers: Bezeichnung, Betrag pro Monat, Quellkonto, Zielkonto
|
||||
- kein Datum
|
||||
- keine einmaligen Buchungen
|
||||
- keine Datenbank
|
||||
- Speicherung ausschließlich in `finance.json`
|
||||
|
||||
### Berechnung
|
||||
|
||||
Je Konto:
|
||||
|
||||
`Eingänge - Ausgänge + Transfers rein - Transfers raus = monatlicher Kontosaldo`
|
||||
|
||||
Gesamt:
|
||||
|
||||
`alle Eingänge - alle Ausgänge = monatlicher Gesamtsaldo`
|
||||
|
||||
Interne Transfers verändern den Gesamtsaldo nicht.
|
||||
|
||||
## Lokal starten
|
||||
|
||||
```bash
|
||||
chmod +x start-local.sh
|
||||
./start-local.sh
|
||||
```
|
||||
|
||||
Die App läuft standardmäßig auf `http://127.0.0.1:3000` und speichert lokal in `.local-data/finance.json`.
|
||||
|
||||
## Docker-Server
|
||||
|
||||
Persistente Daten:
|
||||
|
||||
`/srv/docker/fixfin/data/finance.json`
|
||||
|
||||
Compose / Anwendung:
|
||||
|
||||
`/opt/docker-infra/fixfin`
|
||||
|
||||
Installation:
|
||||
|
||||
```bash
|
||||
sudo chmod +x install.sh
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
Danach: `http://DOCKER-SERVER-IP:3080`
|
||||
|
||||
## Build + Push in Gitea Container Registry
|
||||
|
||||
Vor dem ersten Push:
|
||||
|
||||
```bash
|
||||
sudo docker login git.example.de
|
||||
```
|
||||
|
||||
Dann:
|
||||
|
||||
```bash
|
||||
sudo ./build-and-push-gitea.sh git.example.de owner/fixfin latest
|
||||
```
|
||||
|
||||
Das Image wird als `git.example.de/owner/fixfin:latest` gepusht.
|
||||
|
||||
Standardplattform ist `linux/amd64`. Abweichend z. B.:
|
||||
|
||||
```bash
|
||||
sudo PLATFORM=linux/arm64 ./build-and-push-gitea.sh git.example.de owner/fixfin latest
|
||||
```
|
||||
|
||||
## Registry-Image deployen
|
||||
|
||||
```bash
|
||||
cd /opt/docker-infra/fixfin
|
||||
sudo FIXFIN_IMAGE=git.example.de/owner/fixfin:latest docker compose -f docker-compose.registry.yml up -d
|
||||
```
|
||||
|
||||
## JSON-Migration
|
||||
|
||||
Alte FixFin-v1-Dateien mit `balance` in Konten werden automatisch auf v2 migriert. Die alten Kontostände werden entfernt; Eingänge, Ausgänge und Transfers bleiben erhalten. Vor dem Umschreiben wird `finance.json.bak` angelegt.
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
REGISTRY="${GITEA_REGISTRY:-${1:-}}"
|
||||
IMAGE_PATH="${GITEA_IMAGE_PATH:-${2:-}}"
|
||||
TAG="${IMAGE_TAG:-${3:-latest}}"
|
||||
PLATFORM="${PLATFORM:-linux/amd64}"
|
||||
|
||||
if [[ -z "$REGISTRY" || -z "$IMAGE_PATH" ]]; then
|
||||
cat <<USAGE
|
||||
Verwendung:
|
||||
$0 <registry> <owner/image> [tag]
|
||||
|
||||
Beispiel:
|
||||
$0 git.example.de meinuser/fixfin latest
|
||||
|
||||
Alternativ per Umgebungsvariablen:
|
||||
GITEA_REGISTRY=git.example.de \\
|
||||
GITEA_IMAGE_PATH=meinuser/fixfin \\
|
||||
IMAGE_TAG=latest \\
|
||||
$0
|
||||
|
||||
Vor dem ersten Push anmelden:
|
||||
sudo docker login <registry>
|
||||
USAGE
|
||||
exit 1
|
||||
fi
|
||||
|
||||
command -v docker >/dev/null 2>&1 || { echo "Docker fehlt."; exit 1; }
|
||||
docker buildx version >/dev/null 2>&1 || { echo "Docker Buildx fehlt."; exit 1; }
|
||||
|
||||
FULL_IMAGE="${REGISTRY}/${IMAGE_PATH}:${TAG}"
|
||||
|
||||
echo ">>> Build + Push"
|
||||
echo "Image: ${FULL_IMAGE}"
|
||||
echo "Platform: ${PLATFORM}"
|
||||
echo
|
||||
|
||||
cd "$ROOT"
|
||||
docker buildx build \
|
||||
--platform "$PLATFORM" \
|
||||
--tag "$FULL_IMAGE" \
|
||||
--push \
|
||||
.
|
||||
|
||||
echo
|
||||
echo "Fertig: ${FULL_IMAGE}"
|
||||
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
fixfin:
|
||||
image: ${FIXFIN_IMAGE:?FIXFIN_IMAGE muss gesetzt sein}
|
||||
container_name: fixfin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${FIXFIN_PORT:-3080}:3000"
|
||||
environment:
|
||||
TZ: Europe/Berlin
|
||||
DATA_FILE: /app/data/finance.json
|
||||
volumes:
|
||||
- /srv/docker/fixfin/data:/app/data
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
fixfin:
|
||||
build: .
|
||||
image: fixfin:local
|
||||
container_name: fixfin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3080:3000"
|
||||
environment:
|
||||
TZ: Europe/Berlin
|
||||
DATA_FILE: /app/data/finance.json
|
||||
volumes:
|
||||
- /srv/docker/fixfin/data:/app/data
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<title>FixFin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
SOURCE_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
TARGET_DIR="/opt/docker-infra/fixfin"
|
||||
DATA_DIR="/srv/docker/fixfin/data"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "Bitte mit sudo starten:"
|
||||
echo " sudo bash install.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
command -v docker >/dev/null || { echo "Docker fehlt."; exit 1; }
|
||||
docker compose version >/dev/null || { echo "Docker Compose v2 fehlt."; exit 1; }
|
||||
|
||||
mkdir -p "$TARGET_DIR" "$DATA_DIR"
|
||||
|
||||
# Nur Projektdateien kopieren, niemals /opt oder /srv rekursiv löschen.
|
||||
if [[ "$SOURCE_DIR" != "$TARGET_DIR" ]]; then
|
||||
cp -a "$SOURCE_DIR"/. "$TARGET_DIR"/
|
||||
fi
|
||||
|
||||
# node:alpine verwendet standardmäßig UID/GID 1000 für 'node'.
|
||||
chown -R 1000:1000 "$DATA_DIR"
|
||||
chmod 750 "$DATA_DIR"
|
||||
|
||||
cd "$TARGET_DIR"
|
||||
docker compose config >/dev/null
|
||||
docker compose up -d --build
|
||||
|
||||
echo
|
||||
echo "FixFin läuft."
|
||||
echo "URL: http://DOCKER-SERVER-IP:3080"
|
||||
echo "Daten: $DATA_DIR/finance.json"
|
||||
echo
|
||||
docker compose ps
|
||||
Generated
+2622
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "fixfin",
|
||||
"version": "1.1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"start": "node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "5.1.0",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "5.0.2",
|
||||
"vite": "7.1.2"
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import express from 'express';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const dataFile = process.env.DATA_FILE || '/app/data/finance.json';
|
||||
const dataDir = path.dirname(dataFile);
|
||||
const backupFile = `${dataFile}.bak`;
|
||||
|
||||
const emptyData = () => ({
|
||||
version: 2,
|
||||
accounts: [],
|
||||
incomes: [],
|
||||
expenses: [],
|
||||
transfers: [],
|
||||
});
|
||||
|
||||
function isFiniteNumber(value) {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function normalizeData(input) {
|
||||
if (!input || typeof input !== 'object') return input;
|
||||
return {
|
||||
version: 2,
|
||||
accounts: Array.isArray(input.accounts)
|
||||
? input.accounts.map(({ id, name }) => ({ id, name }))
|
||||
: input.accounts,
|
||||
incomes: input.incomes,
|
||||
expenses: input.expenses,
|
||||
transfers: input.transfers,
|
||||
};
|
||||
}
|
||||
|
||||
function validateData(input) {
|
||||
if (!input || typeof input !== 'object') throw new Error('Ungültiges JSON.');
|
||||
for (const key of ['accounts', 'incomes', 'expenses', 'transfers']) {
|
||||
if (!Array.isArray(input[key])) throw new Error(`Feld '${key}' muss ein Array sein.`);
|
||||
}
|
||||
|
||||
const accountIds = new Set();
|
||||
for (const account of input.accounts) {
|
||||
if (!account.id || typeof account.id !== 'string') throw new Error('Konto ohne gültige ID.');
|
||||
if (!account.name || typeof account.name !== 'string') throw new Error('Konto ohne Namen.');
|
||||
if (accountIds.has(account.id)) throw new Error('Doppelte Konto-ID.');
|
||||
accountIds.add(account.id);
|
||||
}
|
||||
|
||||
for (const collectionName of ['incomes', 'expenses']) {
|
||||
for (const item of input[collectionName]) {
|
||||
if (!item.id || typeof item.id !== 'string') throw new Error('Eintrag ohne gültige ID.');
|
||||
if (!item.name || typeof item.name !== 'string') throw new Error('Eintrag ohne Namen.');
|
||||
if (!isFiniteNumber(item.amount) || item.amount < 0) throw new Error(`Ungültiger Betrag bei '${item.name}'.`);
|
||||
if (!accountIds.has(item.accountId)) throw new Error(`Unbekanntes Konto bei '${item.name}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const transfer of input.transfers) {
|
||||
if (!transfer.id || typeof transfer.id !== 'string') throw new Error('Transfer ohne gültige ID.');
|
||||
if (!transfer.name || typeof transfer.name !== 'string') throw new Error('Transfer ohne Namen.');
|
||||
if (!isFiniteNumber(transfer.amount) || transfer.amount < 0) throw new Error(`Ungültiger Transferbetrag bei '${transfer.name}'.`);
|
||||
if (!accountIds.has(transfer.fromAccountId) || !accountIds.has(transfer.toAccountId)) {
|
||||
throw new Error(`Unbekanntes Konto beim Transfer '${transfer.name}'.`);
|
||||
}
|
||||
if (transfer.fromAccountId === transfer.toAccountId) throw new Error('Quell- und Zielkonto müssen verschieden sein.');
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDataFile() {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
try {
|
||||
await fs.access(dataFile);
|
||||
} catch {
|
||||
await atomicWrite(emptyData(), false);
|
||||
}
|
||||
}
|
||||
|
||||
async function readData() {
|
||||
await ensureDataFile();
|
||||
const raw = await fs.readFile(dataFile, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const data = normalizeData(parsed);
|
||||
validateData(data);
|
||||
|
||||
// Migration von v1: alte account.balance-Werte werden bewusst entfernt.
|
||||
const hadLegacyBalance = Array.isArray(parsed.accounts) && parsed.accounts.some(a => Object.hasOwn(a, 'balance'));
|
||||
if (parsed.version !== 2 || hadLegacyBalance) {
|
||||
await atomicWrite(data, true);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function atomicWrite(input, makeBackup = true) {
|
||||
const data = normalizeData(input);
|
||||
validateData(data);
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
const tmp = `${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
const payload = JSON.stringify(data, null, 2) + '\n';
|
||||
|
||||
if (makeBackup) {
|
||||
try {
|
||||
await fs.copyFile(dataFile, backupFile);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(tmp, payload, { encoding: 'utf8', mode: 0o600 });
|
||||
await fs.rename(tmp, dataFile);
|
||||
}
|
||||
|
||||
app.disable('x-powered-by');
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
app.get('/api/health', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
app.get('/api/data', async (_req, res, next) => {
|
||||
try { res.json(await readData()); } catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.put('/api/data', async (req, res, next) => {
|
||||
try {
|
||||
await atomicWrite(req.body);
|
||||
res.json(await readData());
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.get('/api/export', async (_req, res, next) => {
|
||||
try {
|
||||
const data = await readData();
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="fixfin-backup.json"');
|
||||
res.type('application/json').send(JSON.stringify(data, null, 2) + '\n');
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
const dist = path.resolve(__dirname, '../dist');
|
||||
app.use(express.static(dist));
|
||||
app.use((_req, res) => res.sendFile(path.join(dist, 'index.html')));
|
||||
|
||||
app.use((error, _req, res, _next) => {
|
||||
console.error(error);
|
||||
res.status(400).json({ error: error.message || 'Unbekannter Fehler' });
|
||||
});
|
||||
|
||||
await ensureDataFile();
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`FixFin läuft auf Port ${port}`);
|
||||
console.log(`Datendatei: ${dataFile}`);
|
||||
});
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
|
||||
const emptyData = { version: 2, accounts: [], incomes: [], expenses: [], transfers: [] };
|
||||
|
||||
function formatMoney(value) {
|
||||
return euro.format(Number(value || 0));
|
||||
}
|
||||
|
||||
function parseAmount(value) {
|
||||
if (typeof value === 'number') return value;
|
||||
const raw = String(value).trim();
|
||||
if (!raw) return Number.NaN;
|
||||
const normalized = raw.includes(',') ? raw.replace(/\./g, '').replace(',', '.') : raw;
|
||||
const number = Number(normalized);
|
||||
return Number.isFinite(number) ? number : Number.NaN;
|
||||
}
|
||||
|
||||
function uid() {
|
||||
// crypto.randomUUID() ist in Browsern nur in sicheren Kontexten (HTTPS/localhost)
|
||||
// garantiert verfügbar. FixFin soll aber auch direkt über http://LAN-IP laufen.
|
||||
if (globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = [...bytes].map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
// Letzter Fallback für sehr alte Browser. Für lokale IDs reicht die Kombination.
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function calc(data) {
|
||||
const accountStats = new Map(data.accounts.map(a => [a.id, {
|
||||
account: a,
|
||||
income: 0,
|
||||
expense: 0,
|
||||
transferIn: 0,
|
||||
transferOut: 0,
|
||||
balance: 0,
|
||||
}]));
|
||||
|
||||
for (const item of data.incomes) {
|
||||
const stat = accountStats.get(item.accountId);
|
||||
if (stat) stat.income += item.amount;
|
||||
}
|
||||
for (const item of data.expenses) {
|
||||
const stat = accountStats.get(item.accountId);
|
||||
if (stat) stat.expense += item.amount;
|
||||
}
|
||||
for (const item of data.transfers) {
|
||||
const from = accountStats.get(item.fromAccountId);
|
||||
const to = accountStats.get(item.toAccountId);
|
||||
if (from) from.transferOut += item.amount;
|
||||
if (to) to.transferIn += item.amount;
|
||||
}
|
||||
|
||||
for (const stat of accountStats.values()) {
|
||||
stat.balance = stat.income - stat.expense + stat.transferIn - stat.transferOut;
|
||||
}
|
||||
|
||||
const income = data.incomes.reduce((s, x) => s + x.amount, 0);
|
||||
const expense = data.expenses.reduce((s, x) => s + x.amount, 0);
|
||||
const transferVolume = data.transfers.reduce((s, x) => s + x.amount, 0);
|
||||
const balance = income - expense;
|
||||
|
||||
return {
|
||||
accounts: [...accountStats.values()],
|
||||
income,
|
||||
expense,
|
||||
transferVolume,
|
||||
balance,
|
||||
};
|
||||
}
|
||||
|
||||
function Icon({ name }) {
|
||||
const paths = {
|
||||
dashboard: <><rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/></>,
|
||||
wallet: <><path d="M4 7h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12"/><path d="M16 12h4"/></>,
|
||||
plus: <><path d="M12 5v14M5 12h14"/></>,
|
||||
minus: <><path d="M5 12h14"/></>,
|
||||
transfer: <><path d="m17 3 4 4-4 4"/><path d="M3 7h18"/><path d="m7 21-4-4 4-4"/><path d="M21 17H3"/></>,
|
||||
data: <><path d="M12 3c5 0 9 1.3 9 3s-4 3-9 3-9-1.3-9-3 4-3 9-3Z"/><path d="M3 6v6c0 1.7 4 3 9 3s9-1.3 9-3V6"/><path d="M3 12v6c0 1.7 4 3 9 3s9-1.3 9-3v-6"/></>,
|
||||
};
|
||||
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [data, setData] = useState(emptyData);
|
||||
const [tab, setTab] = useState('dashboard');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [dialog, setDialog] = useState(null);
|
||||
|
||||
const totals = useMemo(() => calc(data), [data]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/data')
|
||||
.then(async r => {
|
||||
if (!r.ok) throw new Error((await r.json()).error || 'Daten konnten nicht geladen werden.');
|
||||
return r.json();
|
||||
})
|
||||
.then(setData)
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function persist(next) {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const r = await fetch('/api/data', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
const result = await r.json();
|
||||
if (!r.ok) throw new Error(result.error || 'Speichern fehlgeschlagen.');
|
||||
setData(result);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
throw e;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function removeAccount(id) {
|
||||
const used = data.incomes.some(x => x.accountId === id) ||
|
||||
data.expenses.some(x => x.accountId === id) ||
|
||||
data.transfers.some(x => x.fromAccountId === id || x.toAccountId === id);
|
||||
if (used) {
|
||||
setError('Das Konto wird noch von Einträgen oder Transfers verwendet. Diese bitte zuerst löschen.');
|
||||
return;
|
||||
}
|
||||
if (confirm('Konto wirklich löschen?')) persist({ ...data, accounts: data.accounts.filter(x => x.id !== id) });
|
||||
}
|
||||
|
||||
function removeItem(type, id) {
|
||||
if (!confirm('Eintrag wirklich löschen?')) return;
|
||||
persist({ ...data, [type]: data[type].filter(x => x.id !== id) });
|
||||
}
|
||||
|
||||
async function importFile(file) {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text);
|
||||
await persist(parsed);
|
||||
setTab('dashboard');
|
||||
} catch (e) {
|
||||
setError(`Import fehlgeschlagen: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="loading">FixFin wird geladen …</div>;
|
||||
|
||||
const nav = [
|
||||
['dashboard', 'dashboard', 'Übersicht'],
|
||||
['accounts', 'wallet', 'Konten'],
|
||||
['incomes', 'plus', 'Eingänge'],
|
||||
['expenses', 'minus', 'Ausgänge'],
|
||||
['transfers', 'transfer', 'Transfers'],
|
||||
['data', 'data', 'Daten'],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">F</div>
|
||||
<div><strong>FixFin</strong><span>Monatsplanung</span></div>
|
||||
</div>
|
||||
<nav>
|
||||
{nav.map(([id, icon, label]) => (
|
||||
<button key={id} className={tab === id ? 'active' : ''} onClick={() => setTab(id)}>
|
||||
<Icon name={icon}/><span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="save-state">{saving ? 'Speichert …' : 'Automatisch gespeichert'}</div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<h1>{nav.find(x => x[0] === tab)?.[2]}</h1>
|
||||
<p>Fixe monatliche Planung ohne Buchungsverlauf.</p>
|
||||
</div>
|
||||
{tab !== 'dashboard' && tab !== 'data' && (
|
||||
<button className="primary" onClick={() => setDialog({ type: tab })}>+ Neu</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{error && <div className="alert"><span>{error}</span><button onClick={() => setError('')}>×</button></div>}
|
||||
|
||||
{tab === 'dashboard' && <Dashboard totals={totals} />}
|
||||
{tab === 'accounts' && <Accounts data={data} onEdit={x => setDialog({ type: 'accounts', item: x })} onDelete={removeAccount} onAdd={() => setDialog({ type: 'accounts' })}/>}
|
||||
{tab === 'incomes' && <Entries title="Fixe Eingänge" type="incomes" items={data.incomes} accounts={data.accounts} onEdit={x => setDialog({ type: 'incomes', item: x })} onDelete={removeItem}/>}
|
||||
{tab === 'expenses' && <Entries title="Fixe Ausgänge" type="expenses" items={data.expenses} accounts={data.accounts} onEdit={x => setDialog({ type: 'expenses', item: x })} onDelete={removeItem}/>}
|
||||
{tab === 'transfers' && <Transfers data={data} onEdit={x => setDialog({ type: 'transfers', item: x })} onDelete={removeItem}/>}
|
||||
{tab === 'data' && <DataPage data={data} importFile={importFile}/>}
|
||||
</main>
|
||||
|
||||
<nav className="mobile-nav">
|
||||
{nav.map(([id, icon, label]) => (
|
||||
<button key={id} className={tab === id ? 'active' : ''} onClick={() => setTab(id)}>
|
||||
<Icon name={icon}/><span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{dialog && <EditorDialog dialog={dialog} data={data} onClose={() => setDialog(null)} onSave={async next => { await persist(next); setDialog(null); }}/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard({ totals }) {
|
||||
return <div className="content-stack">
|
||||
<section className="summary-grid">
|
||||
<Metric label="Fixe Eingänge / Monat" value={totals.income} positive />
|
||||
<Metric label="Fixe Ausgänge / Monat" value={totals.expense} negative />
|
||||
<Metric label="Transfers / Monat" value={totals.transferVolume} />
|
||||
<Metric label="Monatlicher Gesamtsaldo" value={totals.balance} positive={totals.balance >= 0} negative={totals.balance < 0} featured />
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="section-head"><div><h2>Saldo je Konto</h2><p>Nur fixe Monatswerte. Es gibt keinen Kontostand und keinen Buchungsverlauf.</p></div></div>
|
||||
{totals.accounts.length === 0 ? <Empty text="Lege zuerst ein Konto an."/> : (
|
||||
<div className="account-summary-grid">
|
||||
{totals.accounts.map(s => <article className="account-summary" key={s.account.id}>
|
||||
<div className="account-summary-top"><h3>{s.account.name}</h3><span className={s.balance >= 0 ? 'pill good' : 'pill bad'}>{s.balance >= 0 ? '+' : ''}{formatMoney(s.balance)}</span></div>
|
||||
<div className={`big-money ${s.balance >= 0 ? 'good-text' : 'bad-text'}`}>{formatMoney(s.balance)}</div>
|
||||
<div className="muted">monatlich nach allen Fixbewegungen</div>
|
||||
<div className="mini-grid">
|
||||
<div><span>Eingänge</span><strong className="good-text">+{formatMoney(s.income)}</strong></div>
|
||||
<div><span>Ausgänge</span><strong className="bad-text">−{formatMoney(s.expense)}</strong></div>
|
||||
<div><span>Transfers rein</span><strong className="good-text">+{formatMoney(s.transferIn)}</strong></div>
|
||||
<div><span>Transfers raus</span><strong className="bad-text">−{formatMoney(s.transferOut)}</strong></div>
|
||||
</div>
|
||||
</article>)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel total-panel">
|
||||
<div><span className="eyebrow">GESAMT</span><h2>Monatlich nach Fixkosten übrig</h2><p>Interne Transfers verändern den Gesamtsaldo nicht.</p></div>
|
||||
<div className={`grand-total ${totals.balance >= 0 ? 'good-text' : 'bad-text'}`}>{formatMoney(totals.balance)}</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
function Metric({ label, value, positive, negative, featured }) {
|
||||
return <div className={`metric ${featured ? 'featured' : ''}`}><span>{label}</span><strong className={positive ? 'good-text' : negative ? 'bad-text' : ''}>{formatMoney(value)}</strong></div>
|
||||
}
|
||||
|
||||
function Accounts({ data, onEdit, onDelete, onAdd }) {
|
||||
return <section className="panel">
|
||||
<div className="section-head"><div><h2>Konten</h2><p>Konten dienen nur zur Zuordnung deiner monatlichen Fixbewegungen. Es wird kein Kontostand gespeichert.</p></div><button className="secondary desktop-only" onClick={onAdd}>+ Konto</button></div>
|
||||
{data.accounts.length === 0 ? <Empty text="Noch keine Konten vorhanden."/> : <div className="table-wrap"><table><thead><tr><th>Konto</th><th></th></tr></thead><tbody>{data.accounts.map(a => <tr key={a.id}><td><strong>{a.name}</strong></td><td className="actions"><button onClick={() => onEdit(a)}>Bearbeiten</button><button className="danger" onClick={() => onDelete(a.id)}>Löschen</button></td></tr>)}</tbody></table></div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function Entries({ type, items, accounts, onEdit, onDelete }) {
|
||||
const accountName = id => accounts.find(a => a.id === id)?.name || 'Unbekannt';
|
||||
const isIncome = type === 'incomes';
|
||||
return <section className="panel">
|
||||
<div className="section-head"><div><h2>{isIncome ? 'Fixe Eingänge' : 'Fixe Ausgänge'}</h2><p>Alle Beträge gelten monatlich und haben bewusst kein Datum.</p></div></div>
|
||||
{items.length === 0 ? <Empty text={`Noch keine ${isIncome ? 'Eingänge' : 'Ausgänge'} vorhanden.`}/> : <div className="table-wrap"><table><thead><tr><th>Bezeichnung</th><th>Konto</th><th className="number">Monatlich</th><th></th></tr></thead><tbody>{items.map(x => <tr key={x.id}><td><strong>{x.name}</strong></td><td>{accountName(x.accountId)}</td><td className={`number ${isIncome ? 'good-text' : 'bad-text'}`}>{isIncome ? '+' : '−'}{formatMoney(x.amount)}</td><td className="actions"><button onClick={() => onEdit(x)}>Bearbeiten</button><button className="danger" onClick={() => onDelete(type, x.id)}>Löschen</button></td></tr>)}</tbody></table></div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function Transfers({ data, onEdit, onDelete }) {
|
||||
const accountName = id => data.accounts.find(a => a.id === id)?.name || 'Unbekannt';
|
||||
return <section className="panel">
|
||||
<div className="section-head"><div><h2>Fixe Transfers</h2><p>Verschiebungen zwischen eigenen Konten. Global saldoneutral.</p></div></div>
|
||||
{data.transfers.length === 0 ? <Empty text="Noch keine Transfers vorhanden."/> : <div className="table-wrap"><table><thead><tr><th>Bezeichnung</th><th>Von</th><th>Nach</th><th className="number">Monatlich</th><th></th></tr></thead><tbody>{data.transfers.map(x => <tr key={x.id}><td><strong>{x.name}</strong></td><td>{accountName(x.fromAccountId)}</td><td>{accountName(x.toAccountId)}</td><td className="number">{formatMoney(x.amount)}</td><td className="actions"><button onClick={() => onEdit(x)}>Bearbeiten</button><button className="danger" onClick={() => onDelete('transfers', x.id)}>Löschen</button></td></tr>)}</tbody></table></div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function DataPage({ data, importFile }) {
|
||||
return <div className="content-stack">
|
||||
<section className="panel">
|
||||
<div className="section-head"><div><h2>JSON sichern</h2><p>Alle Daten liegen in einer einzigen JSON-Datei. Vor jedem Speichern wird serverseitig zusätzlich eine <code>.bak</code>-Datei angelegt.</p></div></div>
|
||||
<div className="button-row"><a className="primary button-link" href="/api/export">JSON herunterladen</a><label className="secondary button-link">JSON importieren<input type="file" accept="application/json,.json" hidden onChange={e => e.target.files?.[0] && importFile(e.target.files[0])}/></label></div>
|
||||
</section>
|
||||
<section className="panel"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>{data.accounts.length} Konten · {data.incomes.length} Eingänge · {data.expenses.length} Ausgänge · {data.transfers.length} Transfers</p></div></div><pre className="json-preview">{JSON.stringify(data, null, 2)}</pre></section>
|
||||
</div>
|
||||
}
|
||||
|
||||
function Empty({ text }) {
|
||||
return <div className="empty">{text}</div>
|
||||
}
|
||||
|
||||
function EditorDialog({ dialog, data, onClose, onSave }) {
|
||||
const type = dialog.type;
|
||||
const item = dialog.item;
|
||||
const [name, setName] = useState(item?.name || '');
|
||||
const [amount, setAmount] = useState(item?.amount ?? '');
|
||||
const [accountId, setAccountId] = useState(item?.accountId || data.accounts[0]?.id || '');
|
||||
const [fromAccountId, setFromAccountId] = useState(item?.fromAccountId || data.accounts[0]?.id || '');
|
||||
const [toAccountId, setToAccountId] = useState(item?.toAccountId || data.accounts[1]?.id || data.accounts[0]?.id || '');
|
||||
const [localError, setLocalError] = useState('');
|
||||
|
||||
const labels = { accounts: 'Konto', incomes: 'Eingang', expenses: 'Ausgang', transfers: 'Transfer' };
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setLocalError('');
|
||||
const numeric = parseAmount(amount);
|
||||
if (!name.trim()) return setLocalError('Bitte eine Bezeichnung eingeben.');
|
||||
if (type !== 'accounts' && (numeric < 0 || !Number.isFinite(numeric))) return setLocalError('Bitte einen gültigen Betrag eingeben.');
|
||||
if (type !== 'accounts' && data.accounts.length === 0) return setLocalError('Bitte zuerst ein Konto anlegen.');
|
||||
if (type === 'transfers' && fromAccountId === toAccountId) return setLocalError('Quell- und Zielkonto müssen verschieden sein.');
|
||||
|
||||
let next = structuredClone(data);
|
||||
const id = item?.id || uid();
|
||||
let value;
|
||||
if (type === 'accounts') value = { id, name: name.trim() };
|
||||
if (type === 'incomes' || type === 'expenses') value = { id, name: name.trim(), amount: numeric, accountId };
|
||||
if (type === 'transfers') value = { id, name: name.trim(), amount: numeric, fromAccountId, toAccountId };
|
||||
next[type] = item ? next[type].map(x => x.id === item.id ? value : x) : [...next[type], value];
|
||||
try { await onSave(next); } catch (e) { setLocalError(e.message); }
|
||||
}
|
||||
|
||||
return <div className="modal-backdrop" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<form className="modal" onSubmit={submit}>
|
||||
<div className="modal-head"><div><span className="eyebrow">{item ? 'BEARBEITEN' : 'NEU'}</span><h2>{labels[type]}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>
|
||||
{localError && <div className="alert">{localError}</div>}
|
||||
<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e => setName(e.target.value)} placeholder={type === 'accounts' ? 'z. B. Girokonto' : 'z. B. Gehalt'} /></label>
|
||||
{type !== 'accounts' && <label><span>Betrag pro Monat</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e => setAmount(e.target.value)} placeholder="0,00"/><span>€</span></div></label>}
|
||||
{(type === 'incomes' || type === 'expenses') && <label><span>Konto</span><select value={accountId} onChange={e => setAccountId(e.target.value)}>{data.accounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}</select></label>}
|
||||
{type === 'transfers' && <><label><span>Von Konto</span><select value={fromAccountId} onChange={e => setFromAccountId(e.target.value)}>{data.accounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e => setToAccountId(e.target.value)}>{data.accounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}
|
||||
<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button type="submit" className="primary">Speichern</button></div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App/>);
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #e5e7eb;
|
||||
background: #090d14;
|
||||
font-synthesis: none;
|
||||
--bg: #090d14;
|
||||
--panel: #111722;
|
||||
--panel-2: #151d2a;
|
||||
--line: #263244;
|
||||
--text: #f8fafc;
|
||||
--muted: #8c99aa;
|
||||
--accent: #8b9cff;
|
||||
--accent-2: #6f82ff;
|
||||
--good: #64d6a5;
|
||||
--bad: #ff7c86;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 70% 0%, #151d31 0, transparent 36%), var(--bg); color: var(--text); }
|
||||
button,input,select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 250px 1fr; }
|
||||
.sidebar { position: sticky; top: 0; height: 100vh; border-right: 1px solid var(--line); padding: 24px 18px; display: flex; flex-direction: column; background: rgba(9,13,20,.93); backdrop-filter: blur(18px); }
|
||||
.brand { display:flex; gap:12px; align-items:center; padding: 4px 8px 28px; }
|
||||
.brand-mark { width:42px;height:42px;border-radius:13px;display:grid;place-items:center;background:linear-gradient(145deg,var(--accent),#b186ff);font-weight:900;color:#0d1120;font-size:20px;box-shadow:0 10px 30px #6f82ff33; }
|
||||
.brand strong { display:block;font-size:18px;letter-spacing:-.03em; }
|
||||
.brand span { display:block;color:var(--muted);font-size:12px;margin-top:2px; }
|
||||
.sidebar nav { display:grid; gap:7px; }
|
||||
.sidebar nav button,.mobile-nav button { border:0;background:transparent;color:var(--muted);display:flex;align-items:center;gap:12px;padding:11px 12px;border-radius:11px;text-align:left; }
|
||||
.sidebar nav button:hover,.sidebar nav button.active { background:#171f2d;color:white; }
|
||||
.sidebar svg,.mobile-nav svg { width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round; }
|
||||
.save-state { margin-top:auto;font-size:12px;color:#64748b;padding:10px; }
|
||||
main { width:100%; max-width:1500px; margin:0 auto; padding:32px 42px 72px; }
|
||||
.topbar { display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:26px; }
|
||||
h1,h2,h3,p { margin-top:0; }
|
||||
h1 { margin-bottom:5px;font-size:30px;letter-spacing:-.04em; }
|
||||
h2 { font-size:19px;margin-bottom:5px;letter-spacing:-.02em; }
|
||||
h3 { margin:0;font-size:15px; }
|
||||
.topbar p,.section-head p,.total-panel p { color:var(--muted);margin:0;font-size:13px; }
|
||||
.primary,.secondary,.actions button,.close,.button-link { border-radius:10px;border:1px solid transparent;padding:10px 14px;font-weight:700; }
|
||||
.primary { background:var(--accent);color:#0c1120; }
|
||||
.primary:hover { background:#a2afff; }
|
||||
.secondary,.actions button { background:#171f2d;color:#d8e0ea;border-color:#2a374a; }
|
||||
.secondary:hover,.actions button:hover { background:#202b3b; }
|
||||
.content-stack { display:grid;gap:18px; }
|
||||
.summary-grid { display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px; }
|
||||
.metric { background:linear-gradient(145deg,#121925,#0f151f);border:1px solid var(--line);border-radius:16px;padding:20px;min-height:110px;display:flex;flex-direction:column;justify-content:space-between; }
|
||||
.metric.featured { background:linear-gradient(145deg,#172039,#141a2a);border-color:#5364bd; }
|
||||
.metric span { color:var(--muted);font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.055em; }
|
||||
.metric strong { font-size:27px;letter-spacing:-.04em; }
|
||||
.panel { background:rgba(17,23,34,.92);border:1px solid var(--line);border-radius:17px;padding:20px;box-shadow:0 18px 55px #0000001e; }
|
||||
.section-head { display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px; }
|
||||
.account-summary-grid { display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px; }
|
||||
.account-summary { border:1px solid #29364a;background:#0d131c;border-radius:14px;padding:17px; }
|
||||
.account-summary-top { display:flex;justify-content:space-between;align-items:center;gap:8px; }
|
||||
.pill { border-radius:999px;padding:4px 8px;font-size:11px;font-weight:800; }
|
||||
.pill.good { background:#15392f;color:#76e3b6; }
|
||||
.pill.bad { background:#401d25;color:#ff9098; }
|
||||
.big-money { margin-top:18px;font-size:26px;font-weight:850;letter-spacing:-.04em; }
|
||||
.muted { color:var(--muted);font-size:12px;margin-top:2px; }
|
||||
.mini-grid { display:grid;grid-template-columns:1fr 1fr;gap:11px;margin-top:18px;padding-top:15px;border-top:1px solid #202a39; }
|
||||
.mini-grid span { display:block;color:#718096;font-size:10px;text-transform:uppercase;font-weight:750;margin-bottom:3px; }
|
||||
.mini-grid strong { font-size:12px; }
|
||||
.good-text { color:var(--good) !important; }
|
||||
.bad-text { color:var(--bad) !important; }
|
||||
.total-panel { display:flex;align-items:center;justify-content:space-between;gap:25px;background:linear-gradient(120deg,#121a29,#151d31); }
|
||||
.eyebrow { color:var(--accent);font-size:10px;letter-spacing:.12em;font-weight:900; }
|
||||
.grand-total { font-size:36px;font-weight:900;letter-spacing:-.05em; }
|
||||
.table-wrap { overflow:auto; }
|
||||
table { width:100%;border-collapse:collapse;min-width:620px; }
|
||||
th { color:#718096;text-transform:uppercase;font-size:10px;letter-spacing:.07em;text-align:left;padding:10px 12px;border-bottom:1px solid var(--line); }
|
||||
td { padding:14px 12px;border-bottom:1px solid #202a39;font-size:13px; }
|
||||
tbody tr:last-child td { border-bottom:0; }
|
||||
.number { text-align:right;white-space:nowrap; }
|
||||
.actions { text-align:right;white-space:nowrap; }
|
||||
.actions button { padding:6px 9px;font-size:11px;margin-left:6px; }
|
||||
.actions .danger { color:#ff929a; }
|
||||
.empty { border:1px dashed #344055;border-radius:13px;padding:34px;text-align:center;color:var(--muted); }
|
||||
.alert { display:flex;align-items:center;justify-content:space-between;gap:10px;padding:11px 14px;margin-bottom:16px;background:#401d25;border:1px solid #71303d;color:#ffb0b6;border-radius:11px;font-size:13px; }
|
||||
.alert button { background:none;border:0;color:inherit;font-size:20px; }
|
||||
.button-row { display:flex;gap:10px;flex-wrap:wrap; }
|
||||
.button-link { text-decoration:none;display:inline-flex;align-items:center;cursor:pointer; }
|
||||
.json-preview { margin:0;max-height:420px;overflow:auto;background:#0a0f17;border:1px solid #202a39;border-radius:12px;padding:14px;font-size:11px;color:#abb8c8; }
|
||||
.modal-backdrop { position:fixed;inset:0;background:#02050abf;display:grid;place-items:center;padding:20px;z-index:50;backdrop-filter:blur(5px); }
|
||||
.modal { width:min(470px,100%);background:#111722;border:1px solid #334158;border-radius:18px;padding:20px;box-shadow:0 30px 100px #0009; }
|
||||
.modal-head { display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:18px; }
|
||||
.modal-head h2 { font-size:24px;margin-top:3px; }
|
||||
.close { background:transparent;color:#8b99ab;font-size:24px;padding:2px 8px;border:0; }
|
||||
.modal label { display:grid;gap:7px;margin-bottom:14px; }
|
||||
.modal label > span { color:#aab5c4;font-size:12px;font-weight:700; }
|
||||
.modal input,.modal select { width:100%;background:#0b111a;color:white;border:1px solid #2a374a;border-radius:10px;padding:11px 12px;outline:none; }
|
||||
.modal input:focus,.modal select:focus { border-color:var(--accent);box-shadow:0 0 0 3px #8293ff1d; }
|
||||
.money-input { position:relative; }
|
||||
.money-input input { padding-right:38px; }
|
||||
.money-input span { position:absolute;right:12px;top:50%;transform:translateY(-50%);color:#718096; }
|
||||
.modal-actions { display:flex;justify-content:flex-end;gap:9px;margin-top:20px; }
|
||||
.mobile-nav { display:none; }
|
||||
.loading { min-height:100vh;display:grid;place-items:center;color:#94a3b8; }
|
||||
code { color:#b8c3ff; }
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.summary-grid { grid-template-columns:1fr 1fr; }
|
||||
.account-summary-grid { grid-template-columns:1fr 1fr; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.app-shell { display:block; }
|
||||
.sidebar { display:none; }
|
||||
main { padding:22px 16px 100px; }
|
||||
.topbar { align-items:flex-start; }
|
||||
.topbar h1 { font-size:25px; }
|
||||
.topbar p { max-width:240px; }
|
||||
.summary-grid { grid-template-columns:1fr 1fr;gap:9px; }
|
||||
.metric { min-height:96px;padding:15px; }
|
||||
.metric strong { font-size:21px; }
|
||||
.account-summary-grid { grid-template-columns:1fr; }
|
||||
.total-panel { align-items:flex-start;flex-direction:column; }
|
||||
.grand-total { font-size:31px; }
|
||||
.panel { padding:16px;border-radius:14px; }
|
||||
.mobile-nav { position:fixed;bottom:0;left:0;right:0;z-index:40;display:grid;grid-template-columns:repeat(6,1fr);background:#0a0f17f2;border-top:1px solid var(--line);backdrop-filter:blur(18px);padding:7px 5px max(7px, env(safe-area-inset-bottom)); }
|
||||
.mobile-nav button { justify-content:center;flex-direction:column;gap:2px;padding:5px 1px;font-size:9px;border-radius:9px; }
|
||||
.mobile-nav button.active { color:#a7b2ff;background:#161d2b; }
|
||||
.mobile-nav svg { width:18px;height:18px; }
|
||||
.desktop-only { display:none; }
|
||||
.actions button { margin-bottom:4px; }
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
.summary-grid { grid-template-columns:1fr; }
|
||||
.topbar .primary { padding:9px 11px; }
|
||||
}
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
DATA_DIR="${FIXFIN_LOCAL_DATA_DIR:-${ROOT}/.local-data}"
|
||||
PORT="${PORT:-3000}"
|
||||
|
||||
command -v node >/dev/null 2>&1 || { echo "Node.js fehlt (empfohlen: Node 22+)."; exit 1; }
|
||||
command -v npm >/dev/null 2>&1 || { echo "npm fehlt."; exit 1; }
|
||||
|
||||
cd "$ROOT"
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
if [[ ! -d node_modules ]]; then
|
||||
echo ">>> Installiere npm-Abhängigkeiten ..."
|
||||
npm install
|
||||
fi
|
||||
|
||||
echo ">>> Baue Frontend ..."
|
||||
npm run build
|
||||
|
||||
echo
|
||||
echo "FixFin lokal: http://127.0.0.1:${PORT}"
|
||||
echo "JSON: ${DATA_DIR}/finance.json"
|
||||
echo
|
||||
|
||||
PORT="$PORT" DATA_FILE="${DATA_DIR}/finance.json" node server/index.js
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user