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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useSettings } from "../settings";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
import Icon from "../components/Icon";
|
||||
import AccountSelect from "../components/AccountSelect";
|
||||
import CategorySelect from "../components/CategorySelect";
|
||||
import DataTable from "../components/DataTable";
|
||||
import { gruppenFuer } from "../kategorien";
|
||||
@@ -387,9 +388,8 @@ export default function Import() {
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
<span>{t("import.account")}</span>
|
||||
<select value={accountId} onChange={(e) => setAccountId(e.target.value)}>
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.currency})</option>)}
|
||||
</select>
|
||||
<AccountSelect accounts={accounts} value={accountId}
|
||||
onChange={(id) => setAccountId(id != null ? String(id) : "")} />
|
||||
</label>
|
||||
<label className="grow">
|
||||
<span>{t("import.file")}</span>
|
||||
|
||||
@@ -8,6 +8,7 @@ import Icon from "../components/Icon";
|
||||
import IconChip from "../components/IconChip";
|
||||
import Money from "../components/Money";
|
||||
import Modal from "../components/Modal";
|
||||
import AccountSelect from "../components/AccountSelect";
|
||||
import CategorySelect from "../components/CategorySelect";
|
||||
import DataTable from "../components/DataTable";
|
||||
import { gruppenFuer } from "../kategorien";
|
||||
@@ -269,10 +270,11 @@ export default function Transactions() {
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<AccountSelect accounts={accounts} value={filter.account_id} allowNone
|
||||
noneLabel={t("transactions.allAccounts")}
|
||||
placeholder={t("transactions.allAccounts")} showCurrency={false}
|
||||
minWidth={190}
|
||||
onChange={(id) => setFilter({ ...filter, account_id: id != null ? String(id) : "" })} />
|
||||
<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>
|
||||
@@ -318,10 +320,8 @@ export default function Transactions() {
|
||||
<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>
|
||||
<AccountSelect accounts={accounts} value={form.account_id}
|
||||
onChange={(id) => setForm({ ...form, account_id: id != null ? String(id) : "" })} />
|
||||
</label>
|
||||
<label style={{ minWidth: 150 }}>
|
||||
<span>{t("transactions.date")}</span>
|
||||
@@ -332,12 +332,10 @@ export default function Transactions() {
|
||||
{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>
|
||||
<AccountSelect
|
||||
accounts={accounts.filter((a) => String(a.id) !== form.account_id)}
|
||||
value={form.to_account_id}
|
||||
onChange={(id) => setForm({ ...form, to_account_id: id != null ? String(id) : "" })} />
|
||||
</label>
|
||||
) : (
|
||||
<label>
|
||||
|
||||
Reference in New Issue
Block a user