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:
co-authored by
Claude Opus 5
parent
a9ea0432d8
commit
50fc0e3353
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user