feat: Phase 1 – Konten, Kategorien, Transaktionen, EZB-Währungsumrechnung

Backend:
- Modelle + Migration 0002: accounts, account_identifiers, category_groups,
  categories, transactions, exchange_rates (alle owner-gescoped).
- EZB-Kursdienst (90-Tage/Historie) + Backfill-Skript; Umrechnung EUR<->CHF zum
  Buchungsdatum mit lokalem Cache und manueller Übersteuerung; EUR-Pivot
  (amount_eur_cents) eingefroren.
- Kontostände + Netto-Vermögen (Umrechnung in Hauptwährung).
- Router: accounts (inkl. IBAN-Identifikatoren), categories (Gruppen+Kategorien),
  transactions (Ausgabe/Einnahme/Transfer, cross-currency), rates.
- Gruppierte Standard-Kategorien je Nutzer beim Anlegen.
- pytest-Suite: Währung, Salden, Cross-Currency-Transfer, Nutzer-Isolation.

Frontend:
- Seiten Konten, Kategorien, Buchungen; Icon-Chip, Geldbetrag mit Umrechnung,
  Icon-/Farbwähler, Modal; erweitertes SVG-Icon-Set (Konto-/Kategorie-Icons).
- Währungs-/Datumsformatierung (EUR de-DE, CHF de-CH), i18n DE/EN erweitert.
- Live-Umrechnungs-Vorschau im Transfer-Formular.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 18:59:24 +02:00
co-authored by Claude Opus 4.8
parent 2c795a57c8
commit 1b7107b9bd
35 changed files with 3116 additions and 6 deletions
+9
View File
@@ -7,6 +7,9 @@ import BrandMark from "./components/BrandMark";
import Login from "./pages/Login";
import ChangePassword from "./pages/ChangePassword";
import Dashboard from "./pages/Dashboard";
import Accounts from "./pages/Accounts";
import Categories from "./pages/Categories";
import Transactions from "./pages/Transactions";
import Users from "./pages/Users";
import Settings from "./pages/Settings";
@@ -38,6 +41,9 @@ function Sidebar() {
</div>
<nav className="sidebar-nav">
<NavItem to="/" end icon="dashboard" label={t("nav.overview")} />
<NavItem to="/konten" icon="wallet" label={t("nav.accounts")} />
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
<NavItem to="/settings" icon="settings" label={t("nav.settings")} />
{isAdmin && (
<>
@@ -119,6 +125,9 @@ export default function App() {
/>
<Route path="/passwort-aendern" element={<ChangePassword />} />
<Route path="/" element={<Protected wide><Dashboard /></Protected>} />
<Route path="/konten" element={<Protected><Accounts /></Protected>} />
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
<Route path="/settings" element={<Protected><Settings /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="*" element={<Navigate to="/" replace />} />
+38
View File
@@ -124,4 +124,42 @@ export const api = {
listSettings: () => request("/settings"),
setSetting: (key, value) =>
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
// Konten
listAccounts: () => request("/accounts"),
getAccount: (id) => request(`/accounts/${id}`),
createAccount: (body) => request("/accounts", { method: "POST", body }),
updateAccount: (id, body) => request(`/accounts/${id}`, { method: "PATCH", body }),
deleteAccount: (id) => request(`/accounts/${id}`, { method: "DELETE" }),
// Kategorien (Gruppen + Kategorien)
listCategories: () => request("/categories"),
createGroup: (body) => request("/categories/groups", { method: "POST", body }),
updateGroup: (id, body) => request(`/categories/groups/${id}`, { method: "PATCH", body }),
deleteGroup: (id) => request(`/categories/groups/${id}`, { method: "DELETE" }),
createCategory: (body) => request("/categories", { method: "POST", body }),
updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }),
deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }),
// Transaktionen
listTransactions: (params = {}) => {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== "") qs.set(k, v);
}
const s = qs.toString();
return request(`/transactions${s ? `?${s}` : ""}`);
},
getTransaction: (id) => request(`/transactions/${id}`),
createTransaction: (body) => request("/transactions", { method: "POST", body }),
updateTransaction: (id, body) => request(`/transactions/${id}`, { method: "PATCH", body }),
deleteTransaction: (id) => request(`/transactions/${id}`, { method: "DELETE" }),
// Wechselkurse
rateStatus: () => request("/rates/status"),
convertRate: (amountCents, from, to, on) => {
const qs = new URLSearchParams({ amount_cents: amountCents, from_currency: from, to_currency: to });
if (on) qs.set("on", on);
return request(`/rates/convert?${qs.toString()}`);
},
};
+148
View File
@@ -153,6 +153,154 @@ const PATHS = {
<path d="m16.71 13.88.7.71-2.82 2.82" />
</>
),
// --- Kontotyp-Icons -----------------------------------------------------
"credit-card": (
<>
<rect width="20" height="14" x="2" y="5" rx="2" />
<line x1="2" x2="22" y1="10" y2="10" />
</>
),
"trending-up": (
<>
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
<polyline points="16 7 22 7 22 13" />
</>
),
"piggy-bank": (
<>
<path d="M19 5c-1.5 0-2.8 1.4-3 2-3.5-1.5-11-.3-11 5 0 1.8 0 3 2 4.5V20h4v-2h3v2h4v-4c1-.5 1.7-1 2-2h2v-4h-2c0-1-.5-1.5-1-2V5z" />
<path d="M2 9v1c0 1.1.9 2 2 2h1" />
<path d="M16 11h.01" />
</>
),
banknote: (
<>
<rect width="20" height="12" x="2" y="6" rx="2" />
<circle cx="12" cy="12" r="2" />
<path d="M6 12h.01M18 12h.01" />
</>
),
// --- Kategorie-Icons ----------------------------------------------------
home: (
<>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</>
),
shield: (
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
),
wifi: (
<>
<path d="M12 20h.01" />
<path d="M2 8.82a15 15 0 0 1 20 0" />
<path d="M5 12.859a10 10 0 0 1 14 0" />
<path d="M8.5 16.429a5 5 0 0 1 7 0" />
</>
),
"shopping-cart": (
<>
<circle cx="8" cy="21" r="1" />
<circle cx="19" cy="21" r="1" />
<path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12" />
</>
),
utensils: (
<>
<path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2" />
<path d="M7 2v20" />
<path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7" />
</>
),
car: (
<>
<path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2" />
<circle cx="7" cy="17" r="2" />
<path d="M9 17h6" />
<circle cx="17" cy="17" r="2" />
</>
),
"heart-pulse": (
<>
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
<path d="M3.22 12H9.5l.5-1 2 4.5 2-7 1.5 3.5h5.27" />
</>
),
shirt: (
<path d="M20.38 3.46 16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z" />
),
film: (
<>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M7 3v18" />
<path d="M3 7.5h4" />
<path d="M3 12h18" />
<path d="M3 16.5h4" />
<path d="M17 3v18" />
<path d="M17 7.5h4" />
<path d="M17 16.5h4" />
</>
),
plane: (
<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z" />
),
dumbbell: (
<>
<path d="m6.5 6.5 11 11" />
<path d="m21 21-1-1" />
<path d="m3 3 1 1" />
<path d="m18 22 4-4" />
<path d="m2 6 4-4" />
<path d="m3 10 7-7" />
<path d="m14 21 7-7" />
</>
),
"graduation-cap": (
<>
<path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z" />
<path d="M22 10v6" />
<path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5" />
</>
),
"paw-print": (
<>
<circle cx="11" cy="4" r="2" />
<circle cx="18" cy="8" r="2" />
<circle cx="20" cy="16" r="2" />
<path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z" />
</>
),
gift: (
<>
<rect x="3" y="8" width="18" height="4" rx="1" />
<path d="M12 8v13" />
<path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7" />
<path d="M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5" />
</>
),
percent: (
<>
<line x1="19" x2="5" y1="5" y2="19" />
<circle cx="6.5" cy="6.5" r="2.5" />
<circle cx="17.5" cy="17.5" r="2.5" />
</>
),
"more-horizontal": (
<>
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
<circle cx="5" cy="12" r="1" />
</>
),
receipt: (
<>
<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z" />
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8" />
<path d="M12 17.5v-11" />
</>
),
};
export default function Icon({ name, size = 18, className = "", strokeWidth = 2 }) {
+15
View File
@@ -0,0 +1,15 @@
import Icon from "./Icon";
// Farbiges Icon-Plättchen für Konten und Kategorien: getönter Hintergrund,
// Icon in der Akzentfarbe. Farbe kommt aus dem Datensatz (hex).
export default function IconChip({ name, color = "#6b7480", size = 34 }) {
const inner = Math.round(size * 0.56);
return (
<span
className="icon-chip"
style={{ width: size, height: size, color, background: `${color}22` }}
>
<Icon name={name} size={inner} />
</span>
);
}
+32
View File
@@ -0,0 +1,32 @@
import Icon from "./Icon";
export const ACCOUNT_ICONS = [
"wallet", "landmark", "piggy-bank", "credit-card", "trending-up", "banknote", "coins",
];
export const CATEGORY_ICONS = [
"home", "shopping-cart", "utensils", "car", "shield", "heart-pulse", "film",
"refresh-ccw", "shirt", "wifi", "plane", "gift", "graduation-cap", "dumbbell",
"paw-print", "piggy-bank", "percent", "more-horizontal", "banknote", "coins",
"receipt", "list", "landmark", "wallet", "trending-up", "credit-card",
];
// Auswahlgitter für ein Icon. `value` ist der aktuell gewählte Icon-Name.
export default function IconPicker({ icons = CATEGORY_ICONS, value, onChange, color = "#3f51b5" }) {
return (
<div className="icon-picker">
{icons.map((name) => (
<button
key={name}
type="button"
className={`icon-picker-item${value === name ? " selected" : ""}`}
style={value === name ? { color, borderColor: color } : undefined}
onClick={() => onChange(name)}
title={name}
>
<Icon name={name} size={18} />
</button>
))}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import Icon from "./Icon";
// Einfacher, gestalteter Dialog (nutzt die modal-Klassen der Vorlage).
export default function Modal({ title, onClose, children, footer, wide = false }) {
return (
<div
className="modal-backdrop"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="modal" role="dialog" aria-modal="true" style={{ maxWidth: wide ? 640 : 480 }}>
<div className="modal-head">
<h2 style={{ margin: 0 }}>{title}</h2>
<button className="btn-icon" style={{ marginLeft: "auto" }} onClick={onClose} title="Schließen">
<Icon name="close" size={18} />
</button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="btn-pair">{footer}</div>}
</div>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { formatMoney } from "../format";
// Zeigt einen Betrag in Kontowährung und – falls abweichend – zusätzlich den in
// die Hauptwährung umgerechneten Wert. `sign` färbt +/− (grün/rot).
export default function Money({
cents,
currency = "EUR",
baseCents = null,
baseCurrency = "EUR",
sign = null, // "pos" | "neg" | null
showConverted = true,
}) {
const cls = sign ? `amount ${sign}` : "amount";
const prefix = sign === "pos" ? "+" : sign === "neg" ? "−" : "";
const showBase =
showConverted && baseCents != null && currency !== baseCurrency;
return (
<span className="money">
<span className={cls}>
{prefix}
{formatMoney(Math.abs(cents), currency)}
</span>
{showBase && (
<span className="amount-converted">≈ {formatMoney(Math.abs(baseCents), baseCurrency)}</span>
)}
</span>
);
}
+198
View File
@@ -63,3 +63,201 @@
opacity: 0.5;
margin-bottom: var(--sp-3);
}
/* Farbiges Icon-Plättchen (Konten/Kategorien). */
.icon-chip {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
flex: 0 0 auto;
}
/* Betrag + umgerechneter Wert (rechtsbündig gestapelt). */
.money {
display: inline-flex;
flex-direction: column;
align-items: flex-end;
line-height: 1.3;
}
.money .amount {
font-variant-numeric: tabular-nums;
font-weight: 560;
}
/* Icon-Auswahlgitter. */
.icon-picker {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-top: 5px;
}
.icon-picker-item {
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--muted);
cursor: pointer;
transition: border-color .12s, color .12s, background .12s;
}
.icon-picker-item:hover {
color: var(--text);
background: var(--surface-2);
}
.icon-picker-item.selected {
background: var(--accent-soft);
}
/* Farbwähler-Zeile. */
.color-row {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-top: 5px;
}
.color-row input[type="color"] {
width: 44px;
height: 34px;
padding: 2px;
cursor: pointer;
}
/* Name + Icon nebeneinander (Listen). */
.named {
display: flex;
align-items: center;
gap: var(--sp-3);
min-width: 0;
}
.named .named-text {
min-width: 0;
}
.named .named-sub {
color: var(--muted);
font-size: 0.8rem;
}
/* Kategorie-Gruppe (Verwaltung). */
.cat-group + .cat-group {
margin-top: var(--sp-4);
}
.cat-group-head {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-2);
}
.cat-group-head h2 {
margin: 0;
}
.cat-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.cat-row {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
}
.cat-row .grow {
flex: 1;
min-width: 0;
}
/* Seitenkopf mit Titel + Aktion rechts. */
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.page-head h1 {
margin: 0;
}
/* Konten. */
.account-total {
display: flex;
align-items: baseline;
justify-content: space-between;
padding-bottom: var(--sp-3);
margin-bottom: var(--sp-2);
border-bottom: 1px solid var(--border);
}
.account-total strong {
font-size: 1.2rem;
font-variant-numeric: tabular-nums;
}
.account-list {
display: flex;
flex-direction: column;
}
.account-row {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border);
}
.account-row:last-child {
border-bottom: none;
}
.row-actions {
display: flex;
gap: 4px;
flex: 0 0 auto;
}
/* Filterleiste. */
.filter-bar {
display: flex;
gap: var(--sp-3);
flex-wrap: wrap;
margin-bottom: var(--sp-4);
}
.filter-bar select {
width: auto;
min-width: 160px;
margin-top: 0;
}
/* Segmentierte Umschaltung (Ausgabe/Einnahme/Transfer). */
.seg {
display: flex;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
overflow: hidden;
margin-bottom: var(--sp-4);
}
.seg-btn {
flex: 1;
padding: 8px 10px;
border: none;
background: var(--surface);
color: var(--muted);
font-weight: 560;
cursor: pointer;
border-right: 1px solid var(--border);
}
.seg-btn:last-child {
border-right: none;
}
.seg-btn.active {
background: var(--accent);
color: #fff;
}
.nowrap {
white-space: nowrap;
}
+23
View File
@@ -31,3 +31,26 @@ export function formatDate(iso, locale = "de") {
if (Number.isNaN(d.getTime())) return String(iso);
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE").format(d);
}
/** Eingabetext ("12,34" oder "12.34") in Ganzzahl-Cent; null bei leer/ungültig. */
export function parseAmountToCents(str) {
if (str == null) return null;
const cleaned = String(str).trim().replace(/\s/g, "").replace(",", ".");
if (cleaned === "") return null;
const value = Number.parseFloat(cleaned);
if (Number.isNaN(value)) return null;
return Math.round(value * 100);
}
/** Cent-Betrag als bearbeitbarer Eingabetext ("1234" -> "12,34"). */
export function centsToInput(cents) {
if (cents == null) return "";
return (cents / 100).toFixed(2).replace(".", ",");
}
/** Heutiges Datum als YYYY-MM-DD (lokal). */
export function todayIso() {
const d = new Date();
const z = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`;
}
+70
View File
@@ -81,5 +81,75 @@
"server": {
"unreachable": "Server nicht erreichbar.",
"sessionKept": "Deine Anmeldung ist noch gültig – sobald der Server wieder antwortet, geht es weiter."
},
"accounts": {
"title": "Konten",
"new": "Neues Konto",
"edit": "Konto bearbeiten",
"name": "Name",
"type": "Typ",
"currency": "Währung",
"icon": "Symbol",
"color": "Farbe",
"openingBalance": "Anfangssaldo",
"openingDate": "Stichtag",
"identifiers": "Erkennungs-Nummern (IBAN etc.)",
"addIdentifier": "Nummer hinzufügen",
"identShort": "Nr.",
"total": "Gesamt",
"empty": "Noch keine Konten. Lege dein erstes Konto an.",
"deleteConfirm": "Dieses Konto und alle zugehörigen Buchungen löschen?"
},
"accountTypes": {
"girokonto": "Girokonto",
"sparkonto": "Sparkonto",
"bargeld": "Bargeld",
"kreditkarte": "Kreditkarte",
"depot": "Depot / Investment",
"sonstiges": "Sonstiges"
},
"identifierKinds": {
"iban": "IBAN",
"account_no": "Kontonummer",
"card_no": "Kartennummer"
},
"categories": {
"title": "Kategorien",
"newGroup": "Neue Gruppe",
"editGroup": "Gruppe bearbeiten",
"newCategory": "Neue Kategorie",
"editCategory": "Kategorie bearbeiten",
"name": "Name",
"kind": "Art",
"income": "Einnahme",
"expense": "Ausgabe",
"emptyGroup": "Keine Kategorien in dieser Gruppe.",
"deleteConfirm": "Diese Kategorie löschen?",
"deleteGroupConfirm": "Diese Gruppe samt aller Kategorien löschen?"
},
"transactions": {
"title": "Buchungen",
"new": "Neue Buchung",
"edit": "Buchung bearbeiten",
"expense": "Ausgabe",
"income": "Einnahme",
"transfer": "Transfer",
"date": "Datum",
"account": "Konto",
"fromAccount": "Von Konto",
"toAccount": "Auf Konto",
"category": "Kategorie",
"amount": "Betrag",
"toAmount": "Gutschrift",
"payee": "Empfänger",
"description": "Verwendungszweck",
"uncategorized": "Ohne Kategorie",
"empty": "Noch keine Buchungen.",
"needAccount": "Lege zuerst ein Konto an, um Buchungen zu erfassen.",
"allAccounts": "Alle Konten",
"allTypes": "Alle Typen",
"deleteConfirm": "Diese Buchung löschen?",
"amountRequired": "Bitte einen Betrag eingeben.",
"accountRequired": "Bitte ein Konto wählen."
}
}
+70
View File
@@ -81,5 +81,75 @@
"server": {
"unreachable": "Server unreachable.",
"sessionKept": "Your session is still valid – once the server responds again, it will continue."
},
"accounts": {
"title": "Accounts",
"new": "New account",
"edit": "Edit account",
"name": "Name",
"type": "Type",
"currency": "Currency",
"icon": "Icon",
"color": "Color",
"openingBalance": "Opening balance",
"openingDate": "As of",
"identifiers": "Identifiers (IBAN etc.)",
"addIdentifier": "Add identifier",
"identShort": "id(s)",
"total": "Total",
"empty": "No accounts yet. Create your first account.",
"deleteConfirm": "Delete this account and all its transactions?"
},
"accountTypes": {
"girokonto": "Checking",
"sparkonto": "Savings",
"bargeld": "Cash",
"kreditkarte": "Credit card",
"depot": "Brokerage / Investment",
"sonstiges": "Other"
},
"identifierKinds": {
"iban": "IBAN",
"account_no": "Account number",
"card_no": "Card number"
},
"categories": {
"title": "Categories",
"newGroup": "New group",
"editGroup": "Edit group",
"newCategory": "New category",
"editCategory": "Edit category",
"name": "Name",
"kind": "Kind",
"income": "Income",
"expense": "Expense",
"emptyGroup": "No categories in this group.",
"deleteConfirm": "Delete this category?",
"deleteGroupConfirm": "Delete this group and all its categories?"
},
"transactions": {
"title": "Transactions",
"new": "New transaction",
"edit": "Edit transaction",
"expense": "Expense",
"income": "Income",
"transfer": "Transfer",
"date": "Date",
"account": "Account",
"fromAccount": "From account",
"toAccount": "To account",
"category": "Category",
"amount": "Amount",
"toAmount": "Credited",
"payee": "Payee",
"description": "Description",
"uncategorized": "Uncategorized",
"empty": "No transactions yet.",
"needAccount": "Create an account first to record transactions.",
"allAccounts": "All accounts",
"allTypes": "All types",
"deleteConfirm": "Delete this transaction?",
"amountRequired": "Please enter an amount.",
"accountRequired": "Please choose an account."
}
}
+263
View File
@@ -0,0 +1,263 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import { useSettings } from "../settings";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon";
import IconChip from "../components/IconChip";
import Money from "../components/Money";
import Modal from "../components/Modal";
import IconPicker, { ACCOUNT_ICONS } from "../components/IconPicker";
import { centsToInput, formatMoney, parseAmountToCents } from "../format";
const ACCOUNT_TYPES = ["girokonto", "sparkonto", "bargeld", "kreditkarte", "depot", "sonstiges"];
const TYPE_ICON = {
girokonto: "landmark",
sparkonto: "piggy-bank",
bargeld: "wallet",
kreditkarte: "credit-card",
depot: "trending-up",
sonstiges: "wallet",
};
function emptyForm() {
return {
name: "",
type: "girokonto",
currency: "EUR",
icon: "landmark",
color: "#3f51b5",
opening_balance: "",
opening_date: "",
identifiers: [],
};
}
export default function Accounts() {
const { t } = useTranslation();
const { baseCurrency } = useSettings();
const confirm = useConfirm();
const toast = useToast();
const [accounts, setAccounts] = useState([]);
const [editing, setEditing] = useState(null); // Account-Objekt oder null
const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen)
const [busy, setBusy] = useState(false);
async function laden() {
try {
setAccounts(await api.listAccounts());
} catch (err) {
toast(err.message, "error");
}
}
useEffect(() => { laden(); }, []);
function openNew() {
setEditing(null);
setForm(emptyForm());
}
function openEdit(acc) {
setEditing(acc);
setForm({
name: acc.name,
type: acc.type,
currency: acc.currency,
icon: acc.icon,
color: acc.color,
opening_balance: centsToInput(acc.opening_balance_cents),
opening_date: acc.opening_date || "",
identifiers: acc.identifiers.map((i) => ({ kind: i.kind, value: i.value })),
});
}
function setType(type) {
// Icon mitziehen, solange der Nutzer das Standard-Icon des alten Typs hatte.
setForm((f) => ({
...f,
type,
icon: f.icon === TYPE_ICON[f.type] ? TYPE_ICON[type] : f.icon,
}));
}
async function speichern(e) {
e.preventDefault();
setBusy(true);
const body = {
name: form.name.trim(),
type: form.type,
currency: form.currency,
icon: form.icon,
color: form.color,
opening_balance_cents: parseAmountToCents(form.opening_balance) ?? 0,
opening_date: form.opening_date || null,
identifiers: form.identifiers
.filter((i) => i.value.trim())
.map((i) => ({ kind: i.kind, value: i.value.trim() })),
};
try {
if (editing) await api.updateAccount(editing.id, body);
else await api.createAccount(body);
setForm(null);
toast(t("settings.saved"));
laden();
} catch (err) {
toast(err.message, "error");
} finally {
setBusy(false);
}
}
async function loeschen(acc) {
if (!(await confirm({ title: t("common.delete"), message: t("accounts.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
try {
await api.deleteAccount(acc.id);
laden();
} catch (err) {
toast(err.message, "error");
}
}
const total = accounts.reduce((s, a) => s + a.balance_base_cents, 0);
return (
<div>
<div className="page-head">
<h1>{t("accounts.title")}</h1>
<button className="btn primary" onClick={openNew}>
<Icon name="plus" size={16} /> {t("accounts.new")}
</button>
</div>
{accounts.length === 0 ? (
<div className="card"><div className="empty-state">{t("accounts.empty")}</div></div>
) : (
<div className="card">
<div className="account-total">
<span className="muted">{t("accounts.total")}</span>
<strong>{formatMoney(total, baseCurrency)}</strong>
</div>
<div className="account-list">
{accounts.map((a) => (
<div className="account-row" key={a.id}>
<IconChip name={a.icon} color={a.color} />
<div className="named-text grow">
<div><strong>{a.name}</strong></div>
<div className="named-sub">
{t(`accountTypes.${a.type}`)} · {a.currency}
{a.identifiers.length > 0 && ` · ${a.identifiers.length} ${t("accounts.identShort")}`}
</div>
</div>
<Money cents={a.balance_cents} currency={a.currency}
baseCents={a.balance_base_cents} baseCurrency={baseCurrency} />
<div className="row-actions">
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(a)}>
<Icon name="edit" size={15} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(a)}>
<Icon name="trash" size={15} />
</button>
</div>
</div>
))}
</div>
</div>
)}
{form && (
<Modal
title={editing ? t("accounts.edit") : t("accounts.new")}
onClose={() => setForm(null)}
footer={
<>
<button className="btn" onClick={() => setForm(null)}>{t("common.cancel")}</button>
<button className="btn primary" form="account-form" type="submit" disabled={busy}>
{t("common.save")}
</button>
</>
}
>
<form id="account-form" onSubmit={speichern}>
<label>
<span>{t("accounts.name")}</span>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input autoFocus value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label>
<div className="row">
<label className="grow">
<span>{t("accounts.type")}</span>
<select value={form.type} onChange={(e) => setType(e.target.value)}>
{ACCOUNT_TYPES.map((tp) => (
<option key={tp} value={tp}>{t(`accountTypes.${tp}`)}</option>
))}
</select>
</label>
<label style={{ minWidth: 120 }}>
<span>{t("accounts.currency")}</span>
<select value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })}>
<option value="EUR">EUR</option>
<option value="CHF">CHF</option>
</select>
</label>
</div>
<div className="row">
<label className="grow">
<span>{t("accounts.openingBalance")} ({form.currency})</span>
<input inputMode="decimal" value={form.opening_balance}
onChange={(e) => setForm({ ...form, opening_balance: e.target.value })} placeholder="0,00" />
</label>
<label style={{ minWidth: 150 }}>
<span>{t("accounts.openingDate")}</span>
<input type="date" value={form.opening_date}
onChange={(e) => setForm({ ...form, opening_date: e.target.value })} />
</label>
</div>
<label>
<span>{t("accounts.icon")}</span>
<IconPicker icons={ACCOUNT_ICONS} value={form.icon} color={form.color}
onChange={(name) => setForm({ ...form, icon: name })} />
</label>
<label>
<span>{t("accounts.color")}</span>
<div className="color-row">
<input type="color" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
<IconChip name={form.icon} color={form.color} />
</div>
</label>
<label style={{ marginBottom: 6 }}><span>{t("accounts.identifiers")}</span></label>
{form.identifiers.map((ident, idx) => (
<div className="row" key={idx} style={{ marginBottom: 6 }}>
<select style={{ minWidth: 130 }} value={ident.kind}
onChange={(e) => {
const next = [...form.identifiers];
next[idx] = { ...ident, kind: e.target.value };
setForm({ ...form, identifiers: next });
}}>
<option value="iban">IBAN</option>
<option value="account_no">{t("identifierKinds.account_no")}</option>
<option value="card_no">{t("identifierKinds.card_no")}</option>
</select>
<input className="grow" value={ident.value}
onChange={(e) => {
const next = [...form.identifiers];
next[idx] = { ...ident, value: e.target.value };
setForm({ ...form, identifiers: next });
}} />
<button type="button" className="btn sm ghost"
onClick={() => setForm({ ...form, identifiers: form.identifiers.filter((_, i) => i !== idx) })}>
<Icon name="trash" size={15} />
</button>
</div>
))}
<button type="button" className="btn sm"
onClick={() => setForm({ ...form, identifiers: [...form.identifiers, { kind: "iban", value: "" }] })}>
<Icon name="plus" size={15} /> {t("accounts.addIdentifier")}
</button>
</form>
</Modal>
)}
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon";
import IconChip from "../components/IconChip";
import Modal from "../components/Modal";
import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker";
export default function Categories() {
const { t } = useTranslation();
const confirm = useConfirm();
const toast = useToast();
const [groups, setGroups] = useState([]);
const [modal, setModal] = useState(null); // {type:'group'|'category', editing, groupId, form}
async function laden() {
try {
setGroups(await api.listCategories());
} catch (err) {
toast(err.message, "error");
}
}
useEffect(() => { laden(); }, []);
function openGroup(editing = null) {
setModal({
type: "group",
editing,
form: editing
? { name: editing.name, kind: editing.kind, icon: editing.icon, color: editing.color }
: { name: "", kind: "expense", icon: "list", color: "#3f51b5" },
});
}
function openCategory(groupId, editing = null) {
setModal({
type: "category",
editing,
groupId,
form: editing
? { name: editing.name, icon: editing.icon, color: editing.color }
: { name: "", icon: "more-horizontal", color: "#6b7480" },
});
}
async function speichern(e) {
e.preventDefault();
const { type, editing, groupId, form } = modal;
try {
if (type === "group") {
if (editing) await api.updateGroup(editing.id, { name: form.name.trim(), icon: form.icon, color: form.color });
else await api.createGroup({ name: form.name.trim(), kind: form.kind, icon: form.icon, color: form.color });
} else {
if (editing) await api.updateCategory(editing.id, { name: form.name.trim(), icon: form.icon, color: form.color });
else await api.createCategory({ group_id: groupId, name: form.name.trim(), icon: form.icon, color: form.color });
}
setModal(null);
laden();
} catch (err) {
toast(err.message, "error");
}
}
async function loeschenGruppe(g) {
if (!(await confirm({ title: t("common.delete"), message: t("categories.deleteGroupConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
try { await api.deleteGroup(g.id); laden(); } catch (err) { toast(err.message, "error"); }
}
async function loeschenKategorie(c) {
if (!(await confirm({ title: t("common.delete"), message: t("categories.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
try { await api.deleteCategory(c.id); laden(); } catch (err) { toast(err.message, "error"); }
}
return (
<div>
<div className="page-head">
<h1>{t("categories.title")}</h1>
<button className="btn primary" onClick={() => openGroup()}>
<Icon name="plus" size={16} /> {t("categories.newGroup")}
</button>
</div>
{groups.map((g) => (
<div className="card cat-group" key={g.id}>
<div className="cat-group-head">
<IconChip name={g.icon} color={g.color} />
<h2 className="grow">{g.name}</h2>
<span className={`badge ${g.kind === "income" ? "ok" : ""}`}>
{g.kind === "income" ? t("categories.income") : t("categories.expense")}
</span>
<button className="btn sm ghost" title={t("categories.newCategory")} onClick={() => openCategory(g.id)}>
<Icon name="plus" size={15} />
</button>
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openGroup(g)}>
<Icon name="edit" size={15} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschenGruppe(g)}>
<Icon name="trash" size={15} />
</button>
</div>
<div className="cat-list">
{g.categories.map((c) => (
<div className="cat-row" key={c.id}>
<IconChip name={c.icon} color={c.color} size={28} />
<span className="grow">{c.name}</span>
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openCategory(g.id, c)}>
<Icon name="edit" size={14} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschenKategorie(c)}>
<Icon name="trash" size={14} />
</button>
</div>
))}
{g.categories.length === 0 && <div className="muted small">{t("categories.emptyGroup")}</div>}
</div>
</div>
))}
{modal && (
<Modal
title={
modal.type === "group"
? (modal.editing ? t("categories.editGroup") : t("categories.newGroup"))
: (modal.editing ? t("categories.editCategory") : t("categories.newCategory"))
}
onClose={() => setModal(null)}
footer={
<>
<button className="btn" onClick={() => setModal(null)}>{t("common.cancel")}</button>
<button className="btn primary" form="cat-form" type="submit">{t("common.save")}</button>
</>
}
>
<form id="cat-form" onSubmit={speichern}>
<label>
<span>{t("categories.name")}</span>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input autoFocus value={modal.form.name}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, name: e.target.value } })} required />
</label>
{modal.type === "group" && !modal.editing && (
<label>
<span>{t("categories.kind")}</span>
<select value={modal.form.kind}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, kind: e.target.value } })}>
<option value="expense">{t("categories.expense")}</option>
<option value="income">{t("categories.income")}</option>
</select>
</label>
)}
<label>
<span>{t("accounts.icon")}</span>
<IconPicker icons={CATEGORY_ICONS} value={modal.form.icon} color={modal.form.color}
onChange={(name) => setModal({ ...modal, form: { ...modal.form, icon: name } })} />
</label>
<label>
<span>{t("accounts.color")}</span>
<div className="color-row">
<input type="color" value={modal.form.color}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, color: e.target.value } })} />
<IconChip name={modal.form.icon} color={modal.form.color} />
</div>
</label>
</form>
</Modal>
)}
</div>
);
}
+340
View File
@@ -0,0 +1,340 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import { useSettings } from "../settings";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon";
import IconChip from "../components/IconChip";
import Money from "../components/Money";
import Modal from "../components/Modal";
import { centsToInput, parseAmountToCents, todayIso } from "../format";
const TYPES = ["expense", "income", "transfer"];
function emptyForm() {
return {
type: "expense",
date: todayIso(),
account_id: "",
to_account_id: "",
category_id: "",
amount: "",
to_amount: "",
payee: "",
description: "",
};
}
export default function Transactions() {
const { t } = useTranslation();
const { baseCurrency, formatDate } = useSettings();
const confirm = useConfirm();
const toast = useToast();
const [txns, setTxns] = useState([]);
const [accounts, setAccounts] = useState([]);
const [groups, setGroups] = useState([]);
const [filter, setFilter] = useState({ account_id: "", type: "" });
const [form, setForm] = useState(null);
const [editing, setEditing] = useState(null);
const [busy, setBusy] = useState(false);
const accountById = useMemo(() => Object.fromEntries(accounts.map((a) => [a.id, a])), [accounts]);
const categoryById = useMemo(() => {
const m = {};
for (const g of groups) for (const c of g.categories) m[c.id] = c;
return m;
}, [groups]);
async function ladenStamm() {
try {
const [accs, cats] = await Promise.all([api.listAccounts(), api.listCategories()]);
setAccounts(accs);
setGroups(cats);
} catch (err) {
toast(err.message, "error");
}
}
async function ladenBuchungen() {
try {
setTxns(await api.listTransactions({ account_id: filter.account_id, type: filter.type }));
} catch (err) {
toast(err.message, "error");
}
}
useEffect(() => { ladenStamm(); }, []);
useEffect(() => { ladenBuchungen(); }, [filter.account_id, filter.type]);
const fromAcc = form && accountById[Number(form.account_id)];
const toAcc = form && accountById[Number(form.to_account_id)];
const crossCurrency = form?.type === "transfer" && fromAcc && toAcc && fromAcc.currency !== toAcc.currency;
// Bei währungsübergreifenden Transfers den Zielbetrag per EZB-Kurs vorschlagen.
useEffect(() => {
if (!crossCurrency) return;
const cents = parseAmountToCents(form.amount);
if (!cents || form.to_amount) return;
let abbruch = false;
api.convertRate(cents, fromAcc.currency, toAcc.currency, form.date)
.then((r) => {
if (!abbruch && r.converted_cents != null) {
setForm((f) => (f && !f.to_amount ? { ...f, to_amount: centsToInput(r.converted_cents) } : f));
}
})
.catch(() => {});
return () => { abbruch = true; };
}, [crossCurrency, form?.amount, form?.account_id, form?.to_account_id, form?.date]);
function openNew() {
setEditing(null);
setForm({ ...emptyForm(), account_id: accounts[0]?.id ? String(accounts[0].id) : "" });
}
function openEdit(tx) {
setEditing(tx);
setForm({
type: tx.type,
date: tx.date,
account_id: String(tx.account_id),
to_account_id: tx.to_account_id ? String(tx.to_account_id) : "",
category_id: tx.category_id ? String(tx.category_id) : "",
amount: centsToInput(tx.amount_cents),
to_amount: tx.to_amount_cents ? centsToInput(tx.to_amount_cents) : "",
payee: tx.payee || "",
description: tx.description || "",
});
}
const catOptions = useMemo(() => {
if (!form) return [];
const kind = form.type === "income" ? "income" : "expense";
return groups.filter((g) => g.kind === kind);
}, [form, groups]);
async function speichern(e) {
e.preventDefault();
const cents = parseAmountToCents(form.amount);
if (!cents || cents <= 0) { toast(t("transactions.amountRequired"), "error"); return; }
if (!form.account_id) { toast(t("transactions.accountRequired"), "error"); return; }
setBusy(true);
const body = {
type: form.type,
date: form.date,
account_id: Number(form.account_id),
amount_cents: cents,
payee: form.payee.trim() || null,
description: form.description.trim() || null,
};
if (form.type === "transfer") {
body.to_account_id = Number(form.to_account_id);
const toCents = parseAmountToCents(form.to_amount);
if (toCents) body.to_amount_cents = toCents;
} else {
body.category_id = form.category_id ? Number(form.category_id) : null;
}
try {
if (editing) await api.updateTransaction(editing.id, body);
else await api.createTransaction(body);
setForm(null);
toast(t("settings.saved"));
ladenBuchungen();
} catch (err) {
toast(err.message, "error");
} finally {
setBusy(false);
}
}
async function loeschen(tx) {
if (!(await confirm({ title: t("common.delete"), message: t("transactions.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
try { await api.deleteTransaction(tx.id); ladenBuchungen(); } catch (err) { toast(err.message, "error"); }
}
function rowLabel(tx) {
if (tx.type === "transfer") {
const from = accountById[tx.account_id]?.name || "?";
const to = accountById[tx.to_account_id]?.name || "?";
return { icon: "arrow-left-right", color: "#6b7480", title: `${from} → ${to}`, sub: tx.description || t("transactions.transfer") };
}
const cat = tx.category_id ? categoryById[tx.category_id] : null;
return {
icon: cat?.icon || "more-horizontal",
color: cat?.color || "#6b7480",
title: cat?.name || t("transactions.uncategorized"),
sub: tx.payee || tx.description || "",
};
}
return (
<div>
<div className="page-head">
<h1>{t("transactions.title")}</h1>
<button className="btn primary" onClick={openNew} disabled={accounts.length === 0}>
<Icon name="plus" size={16} /> {t("transactions.new")}
</button>
</div>
{accounts.length === 0 ? (
<div className="card"><div className="empty-state">{t("transactions.needAccount")}</div></div>
) : (
<>
<div className="card filter-bar">
<select value={filter.account_id} onChange={(e) => setFilter({ ...filter, account_id: e.target.value })}>
<option value="">{t("transactions.allAccounts")}</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
<select value={filter.type} onChange={(e) => setFilter({ ...filter, type: e.target.value })}>
<option value="">{t("transactions.allTypes")}</option>
<option value="expense">{t("transactions.expense")}</option>
<option value="income">{t("transactions.income")}</option>
<option value="transfer">{t("transactions.transfer")}</option>
</select>
</div>
<div className="card">
{txns.length === 0 ? (
<div className="empty-state">{t("transactions.empty")}</div>
) : (
<table className="table">
<thead>
<tr>
<th>{t("transactions.date")}</th>
<th>{t("transactions.category")}</th>
<th>{t("transactions.account")}</th>
<th style={{ textAlign: "right" }}>{t("transactions.amount")}</th>
<th></th>
</tr>
</thead>
<tbody>
{txns.map((tx) => {
const l = rowLabel(tx);
const acc = accountById[tx.account_id];
const sign = tx.type === "income" ? "pos" : tx.type === "expense" ? "neg" : null;
return (
<tr key={tx.id}>
<td className="nowrap">{formatDate(tx.date)}</td>
<td>
<div className="named">
<IconChip name={l.icon} color={l.color} size={28} />
<div className="named-text">
<div>{l.title}</div>
{l.sub && <div className="named-sub">{l.sub}</div>}
</div>
</div>
</td>
<td className="nowrap">{acc?.name}</td>
<td style={{ textAlign: "right" }}>
<Money cents={tx.amount_cents} currency={acc?.currency || "EUR"}
baseCents={tx.amount_base_cents} baseCurrency={baseCurrency} sign={sign} />
</td>
<td className="nowrap" style={{ textAlign: "right" }}>
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(tx)}>
<Icon name="edit" size={14} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(tx)}>
<Icon name="trash" size={14} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</>
)}
{form && (
<Modal
title={editing ? t("transactions.edit") : t("transactions.new")}
onClose={() => setForm(null)}
footer={
<>
<button className="btn" onClick={() => setForm(null)}>{t("common.cancel")}</button>
<button className="btn primary" form="txn-form" type="submit" disabled={busy}>{t("common.save")}</button>
</>
}
>
<form id="txn-form" onSubmit={speichern}>
<div className="seg">
{TYPES.map((tp) => (
<button key={tp} type="button"
className={`seg-btn${form.type === tp ? " active" : ""}`}
onClick={() => setForm({ ...form, type: tp, category_id: "", to_account_id: "", to_amount: "" })}>
{t(`transactions.${tp}`)}
</button>
))}
</div>
<div className="row">
<label className="grow">
<span>{form.type === "transfer" ? t("transactions.fromAccount") : t("transactions.account")}</span>
<select value={form.account_id} onChange={(e) => setForm({ ...form, account_id: e.target.value })} required>
<option value="" disabled>—</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.currency})</option>)}
</select>
</label>
<label style={{ minWidth: 150 }}>
<span>{t("transactions.date")}</span>
<input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} required />
</label>
</div>
{form.type === "transfer" ? (
<label>
<span>{t("transactions.toAccount")}</span>
<select value={form.to_account_id} onChange={(e) => setForm({ ...form, to_account_id: e.target.value })} required>
<option value="" disabled>—</option>
{accounts.filter((a) => String(a.id) !== form.account_id).map((a) => (
<option key={a.id} value={a.id}>{a.name} ({a.currency})</option>
))}
</select>
</label>
) : (
<label>
<span>{t("transactions.category")}</span>
<select value={form.category_id} onChange={(e) => setForm({ ...form, category_id: e.target.value })}>
<option value="">{t("transactions.uncategorized")}</option>
{catOptions.map((g) => (
<optgroup key={g.id} label={g.name}>
{g.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</optgroup>
))}
</select>
</label>
)}
<div className="row">
<label className="grow">
<span>{t("transactions.amount")} {fromAcc ? `(${fromAcc.currency})` : ""}</span>
<input inputMode="decimal" value={form.amount} placeholder="0,00"
onChange={(e) => setForm({ ...form, amount: e.target.value })} required />
</label>
{crossCurrency && (
<label className="grow">
<span>{t("transactions.toAmount")} ({toAcc.currency})</span>
<input inputMode="decimal" value={form.to_amount} placeholder="0,00"
onChange={(e) => setForm({ ...form, to_amount: e.target.value })} />
</label>
)}
</div>
{form.type !== "transfer" && (
<div className="row">
<label className="grow">
<span>{t("transactions.payee")}</span>
<input value={form.payee} onChange={(e) => setForm({ ...form, payee: e.target.value })} />
</label>
</div>
)}
<label>
<span>{t("transactions.description")}</span>
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</label>
</form>
</Modal>
)}
</div>
);
}