From 6fc2a924d218676e792ced97e497f7f755c701c5 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 15 Oct 2026 10:25:17 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Die=20Abo-Auswahl=20zeigt=20das=20Marke?= =?UTF-8?q?nlogo=20=E2=80=93=20und=20steht=20alphabetisch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das native Dropdown fiel neben der Kategorie-Auswahl auseinander: anderer Stil, kein Suchfeld, kein Symbol, und die Vertraege standen in Anlagereihenfolge. Bei zwanzig Abos sucht man einen bestimmten, und die Reihenfolge der Eingabe kennt niemand. Jetzt dieselbe Auswahl wie bei der Kategorie. CategorySelect bekommt dafuer zwei kleine Zugaenge statt eines zweiten Dropdowns: ein eigenes Symbol je Eintrag (renderMarke) und eine Gruppe ohne Namen, die ohne Kopfzeile auskommt – die Vertraege haben keine Gruppen. Alles andere, von der Suche bis zum Schliessen beim Scrollen, bleibt eine Mechanik. Das Logo schlaegt dabei das Symbol: Netflix erkennt man am roten N, nicht am Wort. Die Fallunterscheidung dafuer stand schon in der Abo-Liste und ist jetzt eine Komponente, die sich beide teilen. Co-Authored-By: Claude Opus 5 --- web/src/components/AboMarke.jsx | 14 +++++++++++++ web/src/components/CategorySelect.jsx | 14 ++++++++++--- web/src/components/DataTable.jsx | 30 ++++++++++++++++++--------- web/src/locales/de.json | 1 + web/src/locales/en.json | 1 + web/src/pages/Abos.jsx | 8 ++----- web/src/pages/Transactions.jsx | 26 ++++++++++++++++++----- 7 files changed, 70 insertions(+), 24 deletions(-) create mode 100644 web/src/components/AboMarke.jsx diff --git a/web/src/components/AboMarke.jsx b/web/src/components/AboMarke.jsx new file mode 100644 index 0000000..f4768c9 --- /dev/null +++ b/web/src/components/AboMarke.jsx @@ -0,0 +1,14 @@ +import { api } from "../api"; +import IconChip from "./IconChip"; + +// Das Logo schlaegt das Symbol: Netflix erkennt man am roten N, nicht am Wort +// – und erst recht nicht an einem Zahnrad. Ohne Logo bleibt das Symbol. +export default function AboMarke({ abo, size = 30 }) { + if (!abo) return null; + return abo.has_logo ? ( + + ) : ( + + ); +} diff --git a/web/src/components/CategorySelect.jsx b/web/src/components/CategorySelect.jsx index 425c3ec..0dcaccc 100644 --- a/web/src/components/CategorySelect.jsx +++ b/web/src/components/CategorySelect.jsx @@ -17,6 +17,10 @@ export default function CategorySelect({ placeholder = "—", minWidth = 0, disabled = false, + // Eigenes Symbol je Eintrag – fuer Abos, die statt eines Icons ein + // Markenlogo tragen. Ohne Angabe bleibt es beim Icon wie bisher. + renderMarke = null, + searchPlaceholder = null, }) { const { t } = useTranslation(); const selectedId = value === "" || value == null ? null : Number(value); @@ -108,6 +112,8 @@ export default function CategorySelect({ } const ql = q.trim().toLowerCase(); + const marke = (c, size) => + (renderMarke ? renderMarke(c, size) : ); return (
@@ -120,7 +126,7 @@ export default function CategorySelect({ > {sel ? ( - + {marke(sel, 22)} {sel.name} ) : ( @@ -143,7 +149,7 @@ export default function CategorySelect({ ref={searchRef} value={q} onChange={(e) => setQ(e.target.value)} - placeholder={t("categories.searchPlaceholder")} + placeholder={searchPlaceholder || t("categories.searchPlaceholder")} />
@@ -165,6 +171,7 @@ export default function CategorySelect({ const isCollapsed = !ql && collapsed.has(g.id); return (
+ {g.name && ( + )} {!isCollapsed && cats.map((c) => ( diff --git a/web/src/components/DataTable.jsx b/web/src/components/DataTable.jsx index d14beeb..0cca1c2 100644 --- a/web/src/components/DataTable.jsx +++ b/web/src/components/DataTable.jsx @@ -30,18 +30,28 @@ export default function DataTable({ }) { const colByKey = useMemo(() => Object.fromEntries(columns.map((c) => [c.key, c])), [columns]); - const [order, setOrder] = useState(() => { - const p = loadPersisted(id); - const saved = Array.isArray(p.order) ? p.order.filter((k) => colByKey[k]) : []; - const rest = columns.map((c) => c.key).filter((k) => !saved.includes(k)); - return [...saved, ...rest]; - }); + // Eine neue Spalte landet dort, wo sie definiert ist – nicht am Ende. + // + // Angehaengt stand sie hinter der Aktionsspalte, also ganz rechts ausserhalb + // des Sichtbereichs: Im Menue angehakt, in der Tabelle unauffindbar. Wer die + // Reihenfolge selbst umgestellt hat, behaelt sie; eingefuegt wird nur die + // neue Spalte, hinter ihrem linken Nachbarn aus der Definition. + const einsortieren = (vorhanden) => { + const alle = columns.map((c) => c.key); + const out = vorhanden.filter((k) => colByKey[k]); + for (const k of alle) { + if (out.includes(k)) continue; + const davor = alle.slice(0, alle.indexOf(k)).reverse().find((x) => out.includes(x)); + out.splice(davor ? out.indexOf(davor) + 1 : 0, 0, k); + } + return out; + }; + + const [order, setOrder] = useState( + () => einsortieren(loadPersisted(id).order || [])); const schluessel = columns.map((c) => c.key).join("|"); useEffect(() => { - setOrder((alt) => { - const fehlend = columns.map((c) => c.key).filter((k) => !alt.includes(k)); - return fehlend.length ? [...alt, ...fehlend] : alt; - }); + setOrder((alt) => (columns.every((c) => alt.includes(c.key)) ? alt : einsortieren(alt))); // eslint-disable-next-line react-hooks/exhaustive-deps }, [schluessel]); diff --git a/web/src/locales/de.json b/web/src/locales/de.json index aa63b7c..19965fd 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -359,6 +359,7 @@ "title": "Abos & Verträge", "noneAssigned": "— kein Abo —", "column": "Abo", + "searchPlaceholder": "Abo suchen …", "new": "Neuer Vertrag", "edit": "Vertrag bearbeiten", "delete": "Löschen", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index c720640..25884f5 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -359,6 +359,7 @@ "title": "Subscriptions & contracts", "noneAssigned": "— no subscription —", "column": "Subscription", + "searchPlaceholder": "Search subscriptions …", "new": "New contract", "edit": "Edit contract", "delete": "Delete", diff --git a/web/src/pages/Abos.jsx b/web/src/pages/Abos.jsx index fd6ce09..6d63f9b 100644 --- a/web/src/pages/Abos.jsx +++ b/web/src/pages/Abos.jsx @@ -6,6 +6,7 @@ import { useConfirm } from "../confirm"; import { useToast } from "../toast"; import Icon from "../components/Icon"; import IconChip from "../components/IconChip"; +import AboMarke from "../components/AboMarke"; import Modal from "../components/Modal"; import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker"; import CategorySelect from "../components/CategorySelect"; @@ -213,12 +214,7 @@ export default function Abos() {
{/* Das Logo schlaegt das Symbol: Netflix erkennt man am roten N, nicht am Wort – und erst recht nicht an einem Zahnrad. */} - {s.has_logo ? ( - - ) : ( - - )} +
{s.name} diff --git a/web/src/pages/Transactions.jsx b/web/src/pages/Transactions.jsx index 7f5ca9c..0595ff0 100644 --- a/web/src/pages/Transactions.jsx +++ b/web/src/pages/Transactions.jsx @@ -11,6 +11,7 @@ import Modal from "../components/Modal"; import BudgetZeitraum from "../components/BudgetZeitraum"; import AccountSelect from "../components/AccountSelect"; import CategorySelect from "../components/CategorySelect"; +import AboMarke from "../components/AboMarke"; import UebertragDialog from "../components/UebertragDialog"; import DataTable from "../components/DataTable"; import { gruppenFuer } from "../kategorien"; @@ -183,6 +184,14 @@ export default function Transactions() { && (toAcc.savings_category_id != null || SAVINGS_TYPES.includes(toAcc.type)); // Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden). + // Eine namenlose Gruppe: CategorySelect zeigt dann keine Kopfzeile. + // Alphabetisch, nicht in Anlagereihenfolge – bei zwanzig Vertraegen sucht + // man einen bestimmten, und die Reihenfolge der Eingabe kennt niemand. + const aboGruppe = useMemo(() => [{ + id: 0, name: null, + categories: [...abos].sort((a, b) => a.name.localeCompare(b.name, i18n.language)), + }], [abos, i18n.language]); + const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]); const sparenCatId = useMemo(() => { @@ -481,11 +490,18 @@ export default function Transactions() { tx.type === "transfer" || links[String(tx.id)] ? ( — ) : ( - + // Dieselbe Auswahl wie bei der Kategorie – nur flach (die Vertraege + // haben keine Gruppen) und mit dem Markenlogo statt eines Symbols. + aboSetzen(tx, id)} + renderMarke={(a, size) => } + searchPlaceholder={t("abos.searchPlaceholder")} + allowNone + noneLabel={t("abos.noneAssigned")} + placeholder={t("abos.noneAssigned")} + /> ) ), },