This commit is contained in:
2026-08-18 06:56:00 +02:00
commit c28c98852b
16 changed files with 3552 additions and 0 deletions
+347
View File
@@ -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
View File
@@ -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; }
}