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")} + /> ) ), },