feat: Die Abo-Auswahl zeigt das Markenlogo – und steht alphabetisch
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0ea2ab1322
commit
6fc2a924d2
@@ -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 ? (
|
||||||
|
<img className="abo-logo" alt="" style={{ width: size, height: size }}
|
||||||
|
src={api.subscriptionLogoUrl(abo.id, abo.logo_version)} />
|
||||||
|
) : (
|
||||||
|
<IconChip name={abo.icon} color={abo.color} size={size} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,10 @@ export default function CategorySelect({
|
|||||||
placeholder = "—",
|
placeholder = "—",
|
||||||
minWidth = 0,
|
minWidth = 0,
|
||||||
disabled = false,
|
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 { t } = useTranslation();
|
||||||
const selectedId = value === "" || value == null ? null : Number(value);
|
const selectedId = value === "" || value == null ? null : Number(value);
|
||||||
@@ -108,6 +112,8 @@ export default function CategorySelect({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ql = q.trim().toLowerCase();
|
const ql = q.trim().toLowerCase();
|
||||||
|
const marke = (c, size) =>
|
||||||
|
(renderMarke ? renderMarke(c, size) : <IconChip name={c.icon} color={c.color} size={size} />);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="catsel" style={{ minWidth }}>
|
<div className="catsel" style={{ minWidth }}>
|
||||||
@@ -120,7 +126,7 @@ export default function CategorySelect({
|
|||||||
>
|
>
|
||||||
{sel ? (
|
{sel ? (
|
||||||
<span className="catsel-value">
|
<span className="catsel-value">
|
||||||
<IconChip name={sel.icon} color={sel.color} size={22} />
|
{marke(sel, 22)}
|
||||||
<span className="ellipsis">{sel.name}</span>
|
<span className="ellipsis">{sel.name}</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -143,7 +149,7 @@ export default function CategorySelect({
|
|||||||
ref={searchRef}
|
ref={searchRef}
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
placeholder={t("categories.searchPlaceholder")}
|
placeholder={searchPlaceholder || t("categories.searchPlaceholder")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="catsel-list">
|
<div className="catsel-list">
|
||||||
@@ -165,6 +171,7 @@ export default function CategorySelect({
|
|||||||
const isCollapsed = !ql && collapsed.has(g.id);
|
const isCollapsed = !ql && collapsed.has(g.id);
|
||||||
return (
|
return (
|
||||||
<div className="catsel-group" key={g.id}>
|
<div className="catsel-group" key={g.id}>
|
||||||
|
{g.name && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="catsel-group-head"
|
className="catsel-group-head"
|
||||||
@@ -178,6 +185,7 @@ export default function CategorySelect({
|
|||||||
<IconChip name={g.icon} color={g.color} size={18} />
|
<IconChip name={g.icon} color={g.color} size={18} />
|
||||||
<span className="ellipsis">{g.name}</span>
|
<span className="ellipsis">{g.name}</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
{!isCollapsed &&
|
{!isCollapsed &&
|
||||||
cats.map((c) => (
|
cats.map((c) => (
|
||||||
<button
|
<button
|
||||||
@@ -186,7 +194,7 @@ export default function CategorySelect({
|
|||||||
className={`catsel-opt${selectedId === c.id ? " selected" : ""}`}
|
className={`catsel-opt${selectedId === c.id ? " selected" : ""}`}
|
||||||
onClick={() => choose(c.id)}
|
onClick={() => choose(c.id)}
|
||||||
>
|
>
|
||||||
<IconChip name={c.icon} color={c.color} size={20} />
|
{marke(c, 20)}
|
||||||
<span className="ellipsis">{c.name}</span>
|
<span className="ellipsis">{c.name}</span>
|
||||||
{selectedId === c.id && <Icon name="check" size={15} className="catsel-check" />}
|
{selectedId === c.id && <Icon name="check" size={15} className="catsel-check" />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -30,18 +30,28 @@ export default function DataTable({
|
|||||||
}) {
|
}) {
|
||||||
const colByKey = useMemo(() => Object.fromEntries(columns.map((c) => [c.key, c])), [columns]);
|
const colByKey = useMemo(() => Object.fromEntries(columns.map((c) => [c.key, c])), [columns]);
|
||||||
|
|
||||||
const [order, setOrder] = useState(() => {
|
// Eine neue Spalte landet dort, wo sie definiert ist – nicht am Ende.
|
||||||
const p = loadPersisted(id);
|
//
|
||||||
const saved = Array.isArray(p.order) ? p.order.filter((k) => colByKey[k]) : [];
|
// Angehaengt stand sie hinter der Aktionsspalte, also ganz rechts ausserhalb
|
||||||
const rest = columns.map((c) => c.key).filter((k) => !saved.includes(k));
|
// des Sichtbereichs: Im Menue angehakt, in der Tabelle unauffindbar. Wer die
|
||||||
return [...saved, ...rest];
|
// 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("|");
|
const schluessel = columns.map((c) => c.key).join("|");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOrder((alt) => {
|
setOrder((alt) => (columns.every((c) => alt.includes(c.key)) ? alt : einsortieren(alt)));
|
||||||
const fehlend = columns.map((c) => c.key).filter((k) => !alt.includes(k));
|
|
||||||
return fehlend.length ? [...alt, ...fehlend] : alt;
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [schluessel]);
|
}, [schluessel]);
|
||||||
|
|
||||||
|
|||||||
@@ -359,6 +359,7 @@
|
|||||||
"title": "Abos & Verträge",
|
"title": "Abos & Verträge",
|
||||||
"noneAssigned": "— kein Abo —",
|
"noneAssigned": "— kein Abo —",
|
||||||
"column": "Abo",
|
"column": "Abo",
|
||||||
|
"searchPlaceholder": "Abo suchen …",
|
||||||
"new": "Neuer Vertrag",
|
"new": "Neuer Vertrag",
|
||||||
"edit": "Vertrag bearbeiten",
|
"edit": "Vertrag bearbeiten",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
|
|||||||
@@ -359,6 +359,7 @@
|
|||||||
"title": "Subscriptions & contracts",
|
"title": "Subscriptions & contracts",
|
||||||
"noneAssigned": "— no subscription —",
|
"noneAssigned": "— no subscription —",
|
||||||
"column": "Subscription",
|
"column": "Subscription",
|
||||||
|
"searchPlaceholder": "Search subscriptions …",
|
||||||
"new": "New contract",
|
"new": "New contract",
|
||||||
"edit": "Edit contract",
|
"edit": "Edit contract",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useConfirm } from "../confirm";
|
|||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import IconChip from "../components/IconChip";
|
import IconChip from "../components/IconChip";
|
||||||
|
import AboMarke from "../components/AboMarke";
|
||||||
import Modal from "../components/Modal";
|
import Modal from "../components/Modal";
|
||||||
import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker";
|
import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker";
|
||||||
import CategorySelect from "../components/CategorySelect";
|
import CategorySelect from "../components/CategorySelect";
|
||||||
@@ -213,12 +214,7 @@ export default function Abos() {
|
|||||||
<div className={"abo-zeile" + beendet}>
|
<div className={"abo-zeile" + beendet}>
|
||||||
{/* Das Logo schlaegt das Symbol: Netflix erkennt man am roten N,
|
{/* Das Logo schlaegt das Symbol: Netflix erkennt man am roten N,
|
||||||
nicht am Wort – und erst recht nicht an einem Zahnrad. */}
|
nicht am Wort – und erst recht nicht an einem Zahnrad. */}
|
||||||
{s.has_logo ? (
|
<AboMarke abo={s} size={30} />
|
||||||
<img className="abo-logo" alt=""
|
|
||||||
src={api.subscriptionLogoUrl(s.id, s.logo_version)} />
|
|
||||||
) : (
|
|
||||||
<IconChip name={s.icon} color={s.color} size={30} />
|
|
||||||
)}
|
|
||||||
<div className="abo-mitte">
|
<div className="abo-mitte">
|
||||||
<div className="abo-kopf">
|
<div className="abo-kopf">
|
||||||
<strong className="ellipsis">{s.name}</strong>
|
<strong className="ellipsis">{s.name}</strong>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Modal from "../components/Modal";
|
|||||||
import BudgetZeitraum from "../components/BudgetZeitraum";
|
import BudgetZeitraum from "../components/BudgetZeitraum";
|
||||||
import AccountSelect from "../components/AccountSelect";
|
import AccountSelect from "../components/AccountSelect";
|
||||||
import CategorySelect from "../components/CategorySelect";
|
import CategorySelect from "../components/CategorySelect";
|
||||||
|
import AboMarke from "../components/AboMarke";
|
||||||
import UebertragDialog from "../components/UebertragDialog";
|
import UebertragDialog from "../components/UebertragDialog";
|
||||||
import DataTable from "../components/DataTable";
|
import DataTable from "../components/DataTable";
|
||||||
import { gruppenFuer } from "../kategorien";
|
import { gruppenFuer } from "../kategorien";
|
||||||
@@ -183,6 +184,14 @@ export default function Transactions() {
|
|||||||
&& (toAcc.savings_category_id != null || SAVINGS_TYPES.includes(toAcc.type));
|
&& (toAcc.savings_category_id != null || SAVINGS_TYPES.includes(toAcc.type));
|
||||||
|
|
||||||
// Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden).
|
// 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 expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
|
||||||
const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]);
|
const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]);
|
||||||
const sparenCatId = useMemo(() => {
|
const sparenCatId = useMemo(() => {
|
||||||
@@ -481,11 +490,18 @@ export default function Transactions() {
|
|||||||
tx.type === "transfer" || links[String(tx.id)] ? (
|
tx.type === "transfer" || links[String(tx.id)] ? (
|
||||||
<span className="muted">—</span>
|
<span className="muted">—</span>
|
||||||
) : (
|
) : (
|
||||||
<select value={tx.subscription_id || ""}
|
// Dieselbe Auswahl wie bei der Kategorie – nur flach (die Vertraege
|
||||||
onChange={(e) => aboSetzen(tx, e.target.value ? Number(e.target.value) : null)}>
|
// haben keine Gruppen) und mit dem Markenlogo statt eines Symbols.
|
||||||
<option value="">{t("abos.noneAssigned")}</option>
|
<CategorySelect
|
||||||
{abos.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
groups={aboGruppe}
|
||||||
</select>
|
value={tx.subscription_id}
|
||||||
|
onChange={(id) => aboSetzen(tx, id)}
|
||||||
|
renderMarke={(a, size) => <AboMarke abo={a} size={size} />}
|
||||||
|
searchPlaceholder={t("abos.searchPlaceholder")}
|
||||||
|
allowNone
|
||||||
|
noneLabel={t("abos.noneAssigned")}
|
||||||
|
placeholder={t("abos.noneAssigned")}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user