feat: Buchungen lassen sich mit eigenen, farbigen Tags versehen und danach filtern

Die Kategorie beantwortet "wofuer war das Geld" und gilt genau einmal. Alles,
was quer dazu liegt – "Urlaub Island", "absetzbar", "zurueckfordern" –, hatte
bisher keinen Platz, weil eine Buchung davon mehrere auf einmal sein kann.

Tags werden auf einer eigenen Seite angelegt, jeder mit seiner Farbe. Gesetzt
werden sie an der Buchung: direkt in der Liste oder beim Bearbeiten. Die
Spalte laesst sich filtern, auch nach mehreren Tags gleichzeitig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-19 07:16:17 +02:00
co-authored by Claude Opus 5
parent a9ea0432d8
commit 50fc0e3353
16 changed files with 961 additions and 11 deletions
+19
View File
@@ -0,0 +1,19 @@
// Farbiges Text-Plättchen für einen Tag.
//
// Nicht `.badge`: das kennt nur feste Varianten (accent/danger/warn/ok), ein
// Tag trägt aber eine frei gewählte Farbe. Der getönte Hintergrund stammt aus
// demselben Kniff wie beim IconChip – `${color}22` ist dieselbe Farbe mit 13 %
// Deckkraft. Dadurch bleibt der Text in Hell UND Dunkel lesbar, ohne dass für
// jede Farbe ein zweiter Wert gepflegt werden müsste.
export default function TagChip({ name, color = "#6b7480", onRemove, removeTitle }) {
return (
<span className="tag-chip" style={{ color, background: `${color}22` }}>
{name}
{onRemove && (
<button type="button" title={removeTitle} onClick={onRemove} aria-label={removeTitle}>
×
</button>
)}
</span>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import Icon from "./Icon";
import TagChip from "./TagChip";
// Mehrfachauswahl für Tags. Bewusst nach dem Vorbild von CategorySelect
// gebaut und mit denselben `.catsel-*`-Klassen: Das Panel wird per Portal mit
// fester Position gerendert, weil es sonst in der scrollbaren Buchungstabelle
// abgeschnitten würde. Unterschied zum Vorbild: flache Liste statt Baum, und
// ein Klick schaltet um statt zu schliessen – man vergibt selten genau einen.
export default function TagSelect({
tags = [],
value = [],
onChange,
minWidth = 0,
disabled = false,
placeholder = null,
}) {
const { t } = useTranslation();
const gewaehlt = useMemo(() => new Set((value || []).map(Number)), [value]);
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [pos, setPos] = useState(null);
const btnRef = useRef(null);
const panelRef = useRef(null);
const searchRef = useRef(null);
const byId = useMemo(() => Object.fromEntries(tags.map((x) => [x.id, x])), [tags]);
const sel = useMemo(
() => [...gewaehlt].map((id) => byId[id]).filter(Boolean),
[gewaehlt, byId],
);
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 >= 220 || 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 });
}
function openPanel() {
if (disabled) return;
computePos();
setQ("");
setOpen(true);
}
useEffect(() => {
if (!open) return undefined;
searchRef.current?.focus();
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);
// Wie beim CategorySelect: Scrollen INNERHALB der Liste darf sie nicht
// schliessen, Scrollen der Seite darunter schon.
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 umschalten(id) {
const next = new Set(gewaehlt);
if (next.has(id)) next.delete(id);
else next.add(id);
// Offen lassen: Wer einen Tag setzt, setzt oft gleich den zweiten.
onChange([...next]);
}
const ql = q.trim().toLowerCase();
const sichtbar = ql ? tags.filter((x) => x.name.toLowerCase().includes(ql)) : tags;
return (
<div className="catsel" style={{ minWidth }}>
<button
type="button"
ref={btnRef}
className={`catsel-btn${open ? " open" : ""}`}
onClick={() => (open ? setOpen(false) : openPanel())}
disabled={disabled}
>
{sel.length ? (
<span className="catsel-value tag-chips">
{sel.map((x) => <TagChip key={x.id} name={x.name} color={x.color} />)}
</span>
) : (
<span className="catsel-value muted ellipsis">
{placeholder || t("tags.choose")}
</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-search">
<Icon name="search" size={14} />
<input
ref={searchRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("tags.searchPlaceholder")}
/>
</div>
<div className="catsel-list">
{sichtbar.map((x) => (
<button
type="button"
key={x.id}
className={`catsel-opt${gewaehlt.has(x.id) ? " selected" : ""}`}
onClick={() => umschalten(x.id)}
>
<TagChip name={x.name} color={x.color} />
<span className="grow" />
{gewaehlt.has(x.id) && <Icon name="check" size={15} className="catsel-check" />}
</button>
))}
{sichtbar.length === 0 && (
<div className="catsel-empty muted small">
{tags.length === 0 ? t("tags.empty") : t("tags.noMatch")}
</div>
)}
</div>
</div>,
document.body,
)}
</div>
);
}