feat: Konto-Auswahl mit Symbol und Farbe

Das native <select> zeigte nur den Namen im Systemstil. Bei einem Dutzend
Konten erkennt man seines am Symbol deutlich schneller – und beim Import
sieht man sofort, ob man in das gemeinte Konto liest.

Neue Komponente AccountSelect nach dem Muster von CategorySelect, mit
denselben Klassen und per Portal gerendert. Eingesetzt beim Import, in
Filter und Formular der Buchungen sowie beim Zielkonto eines Uebertrags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 20:42:14 +02:00
co-authored by Claude Opus 5
parent 6390f7c325
commit 78835d1e40
3 changed files with 148 additions and 17 deletions
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import Icon from "./Icon";
import IconChip from "./IconChip";
// Konto-Auswahl mit Symbol und Kontofarbe. Das native <select> zeigte nur den
// Namen im Systemstil; bei einem Dutzend Konten erkennt man seines am Symbol
// deutlich schneller. Nutzt dieselben Klassen wie CategorySelect und wird per
// Portal gerendert, damit es in Tabellen nicht abgeschnitten wird.
export default function AccountSelect({
accounts = [],
value,
onChange,
allowNone = false,
noneLabel = "—",
placeholder = "—",
showCurrency = true,
minWidth = 0,
disabled = false,
}) {
const { t } = useTranslation();
const selectedId = value === "" || value == null ? null : Number(value);
const [open, setOpen] = useState(false);
const [pos, setPos] = useState(null);
const btnRef = useRef(null);
const panelRef = useRef(null);
const sel = accounts.find((a) => a.id === selectedId) || null;
function computePos() {
const el = btnRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const width = Math.max(r.width, 240);
const spaceBelow = window.innerHeight - r.bottom;
const spaceAbove = r.top;
const desired = 320;
let top;
let maxHeight;
if (spaceBelow >= 200 || spaceBelow >= spaceAbove) {
maxHeight = Math.min(desired, spaceBelow - 10);
top = r.bottom + 4;
} else {
maxHeight = Math.min(desired, spaceAbove - 10);
top = r.top - 4 - maxHeight;
}
let left = r.left;
if (left + width > window.innerWidth - 8) left = window.innerWidth - 8 - width;
if (left < 8) left = 8;
setPos({ left, top, width, maxHeight });
}
useEffect(() => {
if (!open) return undefined;
const onDown = (e) => {
if (panelRef.current?.contains(e.target) || btnRef.current?.contains(e.target)) return;
setOpen(false);
};
const onKey = (e) => e.key === "Escape" && setOpen(false);
// Scrollen IN der Liste darf sie nicht schliessen – nur Scrollen daneben.
const onScrollResize = (e) => {
if (e?.target && panelRef.current?.contains(e.target)) return;
setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
window.addEventListener("scroll", onScrollResize, true);
window.addEventListener("resize", onScrollResize);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScrollResize, true);
window.removeEventListener("resize", onScrollResize);
};
}, [open]);
function choose(id) {
onChange(id);
setOpen(false);
}
const zeile = (a) => (
<span className="catsel-value">
<IconChip name={a.icon} color={a.color} size={22} />
<span className="ellipsis">{a.name}{showCurrency ? ` (${a.currency})` : ""}</span>
</span>
);
return (
<div className="catsel" style={{ minWidth }}>
<button
type="button"
ref={btnRef}
className={`catsel-btn${open ? " open" : ""}`}
onClick={() => (open ? setOpen(false) : (computePos(), setOpen(true)))}
disabled={disabled}
>
{sel ? zeile(sel) : <span className="catsel-value muted ellipsis">{placeholder}</span>}
<Icon name="chevron-down" size={15} className="catsel-caret" />
</button>
{open && pos && createPortal(
<div ref={panelRef} className="catsel-panel"
style={{ left: pos.left, top: pos.top, width: pos.width, maxHeight: pos.maxHeight }}>
<div className="catsel-list">
{allowNone && (
<button type="button"
className={`catsel-opt${selectedId == null ? " selected" : ""}`}
onClick={() => choose(null)}>
<span className="muted ellipsis">{noneLabel}</span>
{selectedId == null && <Icon name="check" size={15} className="catsel-check" />}
</button>
)}
{accounts.map((a) => (
<button type="button" key={a.id}
className={`catsel-opt${a.id === selectedId ? " selected" : ""}`}
onClick={() => choose(a.id)}>
{zeile(a)}
{a.id === selectedId && <Icon name="check" size={15} className="catsel-check" />}
</button>
))}
{accounts.length === 0 && (
<div className="catsel-empty muted">{t("accounts.empty")}</div>
)}
</div>
</div>,
document.body,
)}
</div>
);
}
+3 -3
View File
@@ -5,6 +5,7 @@ import { useSettings } from "../settings";
import { useConfirm } from "../confirm"; import { useConfirm } from "../confirm";
import { useToast } from "../toast"; import { useToast } from "../toast";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import AccountSelect from "../components/AccountSelect";
import CategorySelect from "../components/CategorySelect"; import CategorySelect from "../components/CategorySelect";
import DataTable from "../components/DataTable"; import DataTable from "../components/DataTable";
import { gruppenFuer } from "../kategorien"; import { gruppenFuer } from "../kategorien";
@@ -387,9 +388,8 @@ export default function Import() {
<div className="row"> <div className="row">
<label className="grow"> <label className="grow">
<span>{t("import.account")}</span> <span>{t("import.account")}</span>
<select value={accountId} onChange={(e) => setAccountId(e.target.value)}> <AccountSelect accounts={accounts} value={accountId}
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.currency})</option>)} onChange={(id) => setAccountId(id != null ? String(id) : "")} />
</select>
</label> </label>
<label className="grow"> <label className="grow">
<span>{t("import.file")}</span> <span>{t("import.file")}</span>
+12 -14
View File
@@ -8,6 +8,7 @@ import Icon from "../components/Icon";
import IconChip from "../components/IconChip"; import IconChip from "../components/IconChip";
import Money from "../components/Money"; import Money from "../components/Money";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
import AccountSelect from "../components/AccountSelect";
import CategorySelect from "../components/CategorySelect"; import CategorySelect from "../components/CategorySelect";
import DataTable from "../components/DataTable"; import DataTable from "../components/DataTable";
import { gruppenFuer } from "../kategorien"; import { gruppenFuer } from "../kategorien";
@@ -269,10 +270,11 @@ export default function Transactions() {
) : ( ) : (
<> <>
<div className="card filter-bar"> <div className="card filter-bar">
<select value={filter.account_id} onChange={(e) => setFilter({ ...filter, account_id: e.target.value })}> <AccountSelect accounts={accounts} value={filter.account_id} allowNone
<option value="">{t("transactions.allAccounts")}</option> noneLabel={t("transactions.allAccounts")}
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)} placeholder={t("transactions.allAccounts")} showCurrency={false}
</select> minWidth={190}
onChange={(id) => setFilter({ ...filter, account_id: id != null ? String(id) : "" })} />
<select value={filter.type} onChange={(e) => setFilter({ ...filter, type: e.target.value })}> <select value={filter.type} onChange={(e) => setFilter({ ...filter, type: e.target.value })}>
<option value="">{t("transactions.allTypes")}</option> <option value="">{t("transactions.allTypes")}</option>
<option value="expense">{t("transactions.expense")}</option> <option value="expense">{t("transactions.expense")}</option>
@@ -318,10 +320,8 @@ export default function Transactions() {
<div className="row"> <div className="row">
<label className="grow"> <label className="grow">
<span>{form.type === "transfer" ? t("transactions.fromAccount") : t("transactions.account")}</span> <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> <AccountSelect accounts={accounts} value={form.account_id}
<option value="" disabled>—</option> onChange={(id) => setForm({ ...form, account_id: id != null ? String(id) : "" })} />
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.currency})</option>)}
</select>
</label> </label>
<label style={{ minWidth: 150 }}> <label style={{ minWidth: 150 }}>
<span>{t("transactions.date")}</span> <span>{t("transactions.date")}</span>
@@ -332,12 +332,10 @@ export default function Transactions() {
{form.type === "transfer" ? ( {form.type === "transfer" ? (
<label> <label>
<span>{t("transactions.toAccount")}</span> <span>{t("transactions.toAccount")}</span>
<select value={form.to_account_id} onChange={(e) => setForm({ ...form, to_account_id: e.target.value })} required> <AccountSelect
<option value="" disabled>—</option> accounts={accounts.filter((a) => String(a.id) !== form.account_id)}
{accounts.filter((a) => String(a.id) !== form.account_id).map((a) => ( value={form.to_account_id}
<option key={a.id} value={a.id}>{a.name} ({a.currency})</option> onChange={(id) => setForm({ ...form, to_account_id: id != null ? String(id) : "" })} />
))}
</select>
</label> </label>
) : ( ) : (
<label> <label>