Add persistent multi-tag include and exclude filters
This commit is contained in:
@@ -261,8 +261,11 @@ export function Overview({
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filter))
|
||||
if (value) params.set(key, value);
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const id of value) params.append(key, id);
|
||||
} else if (value) params.set(key, value);
|
||||
}
|
||||
request<Dashboard>(`/api/dashboard?${params}`, undefined, controller.signal)
|
||||
.then((value) => {
|
||||
for (const key of [
|
||||
@@ -498,7 +501,7 @@ export function Overview({
|
||||
groups={dashboard.tags.filter(
|
||||
(g) => g.currency === currency,
|
||||
)}
|
||||
onSelect={(id) => drill({ tag_id: id })}
|
||||
onSelect={(id) => drill({ tag_ids: [id] })}
|
||||
/>
|
||||
</div>
|
||||
<Recurring
|
||||
|
||||
@@ -136,6 +136,7 @@ export function Transactions({
|
||||
(!filter.from || f.booking_date >= filter.from) &&
|
||||
(!filter.to || f.booking_date <= filter.to) &&
|
||||
(!filter.currency || f.currency === filter.currency) &&
|
||||
(!filter.account_id || f.account_id === filter.account_id) &&
|
||||
(!filter.category_id || categories.has(e.category_id || "")) &&
|
||||
(!needsReview ||
|
||||
e.classification.confidence !== "high" ||
|
||||
@@ -149,7 +150,9 @@ export function Transactions({
|
||||
e.classification.source || "",
|
||||
)
|
||||
: e.classification.source === status)) &&
|
||||
(!filter.tag_id || e.tag_ids.includes(filter.tag_id)) &&
|
||||
(!filter.tag_ids.length ||
|
||||
filter.tag_ids.some((id) => e.tag_ids.includes(id))) &&
|
||||
!filter.exclude_tag_ids.some((id) => e.tag_ids.includes(id)) &&
|
||||
(!filter.merchant_id || e.merchant_id === filter.merchant_id) &&
|
||||
(!query ||
|
||||
`${f.raw_description} ${f.counterparty || ""} ${data.merchants.find((m) => m.id === e.merchant_id)?.name || ""} ${f.amount}`
|
||||
|
||||
+4
-2
@@ -214,7 +214,8 @@ export interface Filter {
|
||||
currency: string;
|
||||
account_id: string;
|
||||
category_id: string;
|
||||
tag_id: string;
|
||||
tag_ids: string[];
|
||||
exclude_tag_ids: string[];
|
||||
merchant_id: string;
|
||||
}
|
||||
export interface Preview {
|
||||
@@ -578,7 +579,8 @@ export const emptyFilter: Filter = {
|
||||
currency: "",
|
||||
account_id: "",
|
||||
category_id: "",
|
||||
tag_id: "",
|
||||
tag_ids: [],
|
||||
exclude_tag_ids: [],
|
||||
merchant_id: "",
|
||||
};
|
||||
// A six-month window is the default view: long enough to show a trend and a
|
||||
|
||||
+33
-1
@@ -64,7 +64,39 @@ function App() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [mobileNav, setMobileNav] = useState(false);
|
||||
const [filter, setFilter] = useState(defaultFilter);
|
||||
const [filter, setFilter] = useState(() => {
|
||||
const initial = defaultFilter();
|
||||
try {
|
||||
const saved = JSON.parse(
|
||||
localStorage.getItem("finance-duck.tag-filters") || "null",
|
||||
);
|
||||
for (const key of ["tag_ids", "exclude_tag_ids"] as const) {
|
||||
if (Array.isArray(saved?.[key])) {
|
||||
initial[key] = [
|
||||
...new Set<string>(
|
||||
saved[key].filter((id: unknown) => typeof id === "string" && id),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Unavailable storage or an invalid saved value must not block the journal.
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
"finance-duck.tag-filters",
|
||||
JSON.stringify({
|
||||
tag_ids: filter.tag_ids,
|
||||
exclude_tag_ids: filter.exclude_tag_ids,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Filters still work for this visit when browser storage is unavailable.
|
||||
}
|
||||
}, [filter.tag_ids, filter.exclude_tag_ids]);
|
||||
const acceptState = useCallback((value: State, message?: string) => {
|
||||
setState(normalizeState(value));
|
||||
setConflict(false);
|
||||
|
||||
@@ -2385,6 +2385,43 @@ footer span:first-child {
|
||||
padding-top: 13px;
|
||||
background: transparent;
|
||||
}
|
||||
.tag-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px 20px;
|
||||
padding: 0 18px 17px;
|
||||
}
|
||||
.tag-filter {
|
||||
flex: 1 1 250px;
|
||||
min-width: 0;
|
||||
}
|
||||
.tag-filter .field {
|
||||
gap: 6px;
|
||||
}
|
||||
.tag-filter .combo > input {
|
||||
min-height: 35px;
|
||||
padding: 7px 9px;
|
||||
font-size: 12px;
|
||||
background: #fcfdfe;
|
||||
}
|
||||
.tag-filter .tag-edit {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.tag-filter .tag-chip {
|
||||
min-height: 32px;
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.tag-filter .tag-chip span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.tag-filter .tag-chip svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tag-filter .tag-chip.excluded {
|
||||
border-color: #e8cece;
|
||||
color: var(--danger);
|
||||
}
|
||||
.chip {
|
||||
border: 1px solid #dde4ea;
|
||||
background: #fcfdfe;
|
||||
@@ -2664,6 +2701,9 @@ footer span:first-child {
|
||||
.range-row .filter-reset {
|
||||
margin-left: 0;
|
||||
}
|
||||
.tag-filters {
|
||||
padding: 0 13px 13px;
|
||||
}
|
||||
.chart-body {
|
||||
padding: 4px 12px 18px;
|
||||
}
|
||||
|
||||
+72
-15
@@ -829,8 +829,10 @@ export function Filters({
|
||||
value: Filter;
|
||||
onChange: (filter: Filter) => void;
|
||||
}) {
|
||||
const update = (key: keyof Filter, text: string) =>
|
||||
onChange({ ...value, [key]: text });
|
||||
const update = (
|
||||
key: Exclude<keyof Filter, "tag_ids" | "exclude_tag_ids">,
|
||||
text: string,
|
||||
) => onChange({ ...value, [key]: text });
|
||||
const currencies = Array.from(
|
||||
new Set([
|
||||
...data.accounts.map((a) => a.currency),
|
||||
@@ -849,6 +851,22 @@ export function Filters({
|
||||
{ label: "YTD", title: "Year to date", from: yearStart(), to: "" },
|
||||
{ label: "All", title: "All time", from: "", to: "" },
|
||||
];
|
||||
const tagFilters = [
|
||||
{
|
||||
key: "tag_ids",
|
||||
opposite: "exclude_tag_ids",
|
||||
label: "Include tags",
|
||||
polarity: "Include",
|
||||
hint: "Match any selected tag; empty includes all.",
|
||||
},
|
||||
{
|
||||
key: "exclude_tag_ids",
|
||||
opposite: "tag_ids",
|
||||
label: "Exclude tags",
|
||||
polarity: "Exclude",
|
||||
hint: "Hide transactions with any selected tag.",
|
||||
},
|
||||
] as const;
|
||||
return (
|
||||
<div className="filter-bar">
|
||||
<div className="range-row">
|
||||
@@ -928,19 +946,6 @@ export function Filters({
|
||||
<CategoryOptions data={data} />
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Tag">
|
||||
<select
|
||||
value={value.tag_id}
|
||||
onChange={(e) => update("tag_id", e.target.value)}
|
||||
>
|
||||
<option value="">All tags</option>
|
||||
{data.tags.map((t) => (
|
||||
<option value={t.id} key={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Merchant">
|
||||
<select
|
||||
value={value.merchant_id}
|
||||
@@ -955,6 +960,58 @@ export function Filters({
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="tag-filters">
|
||||
{tagFilters.map(({ key, opposite, label, polarity, hint }) => (
|
||||
<div className="tag-filter" key={key}>
|
||||
<Field label={label} hint={hint}>
|
||||
<Combobox
|
||||
options={data.tags
|
||||
.filter((tag) => !value[key].includes(tag.id))
|
||||
.map((tag) => ({ value: tag.id, label: tag.name }))}
|
||||
value=""
|
||||
placeholder={`Add tag to ${polarity.toLowerCase()}`}
|
||||
emptyText="No more matching tags."
|
||||
onChange={(id) =>
|
||||
onChange({
|
||||
...value,
|
||||
[key]: value[key].includes(id)
|
||||
? value[key]
|
||||
: [...value[key], id],
|
||||
[opposite]: value[opposite].filter((tag) => tag !== id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{value[key].length > 0 && (
|
||||
<div className="tag-edit" role="group" aria-label={label}>
|
||||
{value[key].map((id) => {
|
||||
const name =
|
||||
data.tags.find((tag) => tag.id === id)?.name || id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`tag-chip ${key === "exclude_tag_ids" ? "excluded" : ""}`}
|
||||
key={id}
|
||||
aria-label={`Remove ${name} from ${polarity.toLowerCase()} tags`}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...value,
|
||||
[key]: value[key].filter((tag) => tag !== id),
|
||||
})
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{polarity}: {name}
|
||||
</span>
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user