diff --git a/src/components/configuration/project-system-templates-table.tsx b/src/components/configuration/project-system-templates-table.tsx
index 01171a2e..8719dbb2 100644
--- a/src/components/configuration/project-system-templates-table.tsx
+++ b/src/components/configuration/project-system-templates-table.tsx
@@ -10,13 +10,14 @@ import { toast } from 'sonner';
import { deleteTemplateAction } from '@/actions/templates/mutations/delete-template';
import { duplicateTemplateAction } from '@/actions/templates/mutations/duplicate-template';
import { getProjectSystemTemplatesOverviewAction } from '@/actions/templates/queries/get-project-system-templates-overview';
-import type { ProjectSystemTemplateOverviewRow } from '@/lib/templates/project-system-templates-overview';
import { ConfirmDialog } from '@/components/generic/confirm-dialog';
import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table';
+import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getDatasetDisplayName } from '@/lib/templates/merge-tags';
+import type { ProjectSystemTemplateOverviewRow } from '@/lib/templates/project-system-templates-overview';
interface ProjectSystemTemplatesTableProps {
projectId: string;
@@ -92,7 +93,8 @@ export function ProjectSystemTemplatesTable({ projectId, initialRows }: ProjectS
() => [
{
accessorKey: 'name',
- header: t('list.columns.name'),
+ enableSorting: false,
+ header: ({ column }) => ,
cell: ({ row }) => (
{row.original.name}
@@ -115,17 +117,32 @@ export function ProjectSystemTemplatesTable({ projectId, initialRows }: ProjectS
},
{
accessorKey: 'type',
- header: t('list.columns.type'),
+ enableSorting: false,
+ header: ({ column }) => ,
cell: ({ row }) => {row.original.type === 'EMAIL' ? t('types.email') : t('types.document')},
},
{
accessorKey: 'dataset',
- header: t('list.columns.dataset'),
+ enableSorting: false,
+ header: ({ column }) => (
+
+ ),
cell: ({ row }) => {getDatasetDisplayName(row.original.dataset)},
},
{
accessorKey: 'createdAt',
- header: t('list.columns.createdAt'),
+ enableSorting: false,
+ header: ({ column }) => (
+
+ ),
cell: ({ row }) => {new Intl.DateTimeFormat('de-DE').format(new Date(row.original.createdAt))},
},
],
diff --git a/src/components/dashboard/widgets/filters/entity-filter-control.tsx b/src/components/dashboard/widgets/filters/entity-filter-control.tsx
index b8d80f02..696bdde6 100644
--- a/src/components/dashboard/widgets/filters/entity-filter-control.tsx
+++ b/src/components/dashboard/widgets/filters/entity-filter-control.tsx
@@ -59,6 +59,7 @@ export function EntityFilterControl({
onChange(v)}
variant="stacked"
size="sm"
diff --git a/src/components/filters/date-filter-with-operator.tsx b/src/components/filters/date-filter-with-operator.tsx
index 1ae310a5..acb541e3 100644
--- a/src/components/filters/date-filter-with-operator.tsx
+++ b/src/components/filters/date-filter-with-operator.tsx
@@ -76,11 +76,16 @@ function RelativeDateAmountFields({
{
const raw = e.target.value;
+ if (raw === '') {
+ onChange({ amount: null, unit: value.unit });
+ return;
+ }
+ const parsed = Number.parseInt(raw, 10);
onChange({
- amount: raw === '' ? 0 : Number.parseInt(raw, 10) || 0,
+ amount: Number.isFinite(parsed) ? parsed : null,
unit: value.unit,
});
}}
diff --git a/src/components/filters/filter-field-group.tsx b/src/components/filters/filter-field-group.tsx
index eb6ee030..fa674e6e 100644
--- a/src/components/filters/filter-field-group.tsx
+++ b/src/components/filters/filter-field-group.tsx
@@ -94,6 +94,14 @@ export function filterValueSegmentClass(size: FilterFieldSize = 'default') {
);
}
+/** Compact fixed-width number inputs (narrower than flex text fields). */
+export function filterNumberValueSegmentClass(size: FilterFieldSize = 'default') {
+ return cn(
+ 'w-24 shrink-0 shadow-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
+ filterSizeClass(size),
+ );
+}
+
/** Comparison symbols (=, >, <, …) read small at default select size. */
export const FILTER_SYMBOL_OPERATORS = new Set(['eq', 'gt', 'lt', 'gte', 'lte']);
diff --git a/src/components/filters/number-filter-with-operator.tsx b/src/components/filters/number-filter-with-operator.tsx
index 51e8ee0e..5b769736 100644
--- a/src/components/filters/number-filter-with-operator.tsx
+++ b/src/components/filters/number-filter-with-operator.tsx
@@ -1,15 +1,15 @@
'use client';
import { useTranslations } from 'next-intl';
-import { useMemo, type ReactNode } from 'react';
+import { useMemo, type KeyboardEvent, type ReactNode } from 'react';
import {
FilterFieldGroup,
FilterStackedFields,
+ filterNumberValueSegmentClass,
filterOperatorSegmentClass,
filterSymbolOperatorLabelClass,
filterSymbolOperatorTriggerClass,
- filterValueSegmentClass,
type FilterFieldSize,
type FilterFieldVariant,
} from '@/components/filters/filter-field-group';
@@ -20,11 +20,18 @@ import {
createDefaultNumberFilterValueForOperator,
NUMBER_FILTER_EMPTY_OPERATORS,
NUMBER_FILTER_OPERATORS,
+ type NumberFilterOperator,
type NumberFilterOperatorWithEmpty,
type NumberFilterValue,
parseNumberFilterValue,
} from '@/types/number-filter-value';
+const NUMBER_OPERATOR_SHORTCUTS: Record = {
+ '=': 'eq',
+ '>': 'gt',
+ '<': 'lt',
+};
+
function hasPayload(operator: NumberFilterOperatorWithEmpty): boolean {
return operator !== 'empty' && operator !== 'notEmpty';
}
@@ -33,6 +40,7 @@ export function NumberFilterWithOperator({
value,
onChange,
allowEmpty = false,
+ defaultOperator = 'between',
translationNamespace = 'dataTable',
variant = 'row',
size = 'default',
@@ -40,12 +48,16 @@ export function NumberFilterWithOperator({
value: unknown;
onChange: (value: NumberFilterValue) => void;
allowEmpty?: boolean;
+ defaultOperator?: NumberFilterOperator;
translationNamespace?: string;
variant?: FilterFieldVariant;
size?: FilterFieldSize;
}) {
const t = useTranslations(translationNamespace);
- const parsed = useMemo(() => parseNumberFilterValue(value), [value]);
+ const parsed = useMemo(
+ () => parseNumberFilterValue(value, defaultOperator),
+ [value, defaultOperator],
+ );
const availableOperators = useMemo(() => {
const emptyOps = allowEmpty ? [...NUMBER_FILTER_EMPTY_OPERATORS] : [];
@@ -59,6 +71,21 @@ export function NumberFilterWithOperator({
onChange(createDefaultNumberFilterValueForOperator(operator));
};
+ const handleOperatorShortcut = (
+ event: KeyboardEvent,
+ fieldValue: number | null,
+ ) => {
+ const operator = NUMBER_OPERATOR_SHORTCUTS[event.key];
+ if (!operator) {
+ return;
+ }
+ event.preventDefault();
+ if (parsed.operator === operator) {
+ return;
+ }
+ onChange({ operator, value: fieldValue });
+ };
+
const showPayload = hasPayload(parsed.operator);
const useStackedLayout = variant === 'stacked' && showPayload;
@@ -95,6 +122,7 @@ export function NumberFilterWithOperator({
type="number"
placeholder={t('numberFilterMin')}
value={parsed.min ?? ''}
+ onKeyDown={(e) => handleOperatorShortcut(e, parsed.min)}
onChange={(e) => {
const next = e.target.value === '' ? null : Number(e.target.value);
onChange({
@@ -103,12 +131,13 @@ export function NumberFilterWithOperator({
max: parsed.max,
});
}}
- className={filterValueSegmentClass(size)}
+ className={filterNumberValueSegmentClass(size)}
/>
handleOperatorShortcut(e, parsed.max)}
onChange={(e) => {
const next = e.target.value === '' ? null : Number(e.target.value);
onChange({
@@ -117,7 +146,7 @@ export function NumberFilterWithOperator({
max: next,
});
}}
- className={filterValueSegmentClass(size)}
+ className={filterNumberValueSegmentClass(size)}
/>
>
);
@@ -133,6 +162,7 @@ export function NumberFilterWithOperator({
type="number"
placeholder={t('numberFilterValue')}
value={parsed.value ?? ''}
+ onKeyDown={(e) => handleOperatorShortcut(e, parsed.value)}
onChange={(e) => {
const next = e.target.value === '' ? null : Number(e.target.value);
onChange({
@@ -140,7 +170,7 @@ export function NumberFilterWithOperator({
value: next,
});
}}
- className={filterValueSegmentClass(size)}
+ className={filterNumberValueSegmentClass(size)}
/>
);
}
diff --git a/src/components/lenders/loan-selector-item-row.tsx b/src/components/lenders/loan-selector-item-row.tsx
index 1d004afb..25612717 100644
--- a/src/components/lenders/loan-selector-item-row.tsx
+++ b/src/components/lenders/loan-selector-item-row.tsx
@@ -41,7 +41,7 @@ export function LoanSelectorItemRow({ loan, highlightActive = true }: LoanSelect
{interestRateStr}
- {tLoan('table.balanceShort')} {balanceStr}
+ {tLoan('table.balanceLabel')}: {balanceStr}
diff --git a/src/components/lenders/loan-selector-item.tsx b/src/components/lenders/loan-selector-item.tsx
index a658fb5e..c8a4e9e0 100644
--- a/src/components/lenders/loan-selector-item.tsx
+++ b/src/components/lenders/loan-selector-item.tsx
@@ -36,7 +36,7 @@ export function LoanSelectorItem({ loan }: LoanSelectorItemProps) {
{interestRateStr}
- {tLoan('table.balanceShort')} {balanceStr}
+ {tLoan('table.balanceLabel')}: {balanceStr}
diff --git a/src/components/templates/template-list.tsx b/src/components/templates/template-list.tsx
index 95ac9ec6..c2183000 100644
--- a/src/components/templates/template-list.tsx
+++ b/src/components/templates/template-list.tsx
@@ -11,6 +11,7 @@ import { deleteTemplateAction } from '@/actions/templates/mutations/delete-templ
import { duplicateTemplateAction } from '@/actions/templates/mutations/duplicate-template';
import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table';
+import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useRouter } from '@/i18n/navigation';
@@ -103,7 +104,8 @@ export function TemplateList({ project, templates: externalTemplates, isAdmin }:
() => [
{
accessorKey: 'name',
- header: t('list.columns.name'),
+ enableSorting: false,
+ header: ({ column }) => ,
cell: ({ row }) => (
{row.original.type === 'EMAIL' ? (
@@ -132,17 +134,32 @@ export function TemplateList({ project, templates: externalTemplates, isAdmin }:
},
{
accessorKey: 'type',
- header: t('list.columns.type'),
+ enableSorting: false,
+ header: ({ column }) =>
,
cell: ({ row }) =>
{row.original.type === 'EMAIL' ? t('types.email') : t('types.document')},
},
{
accessorKey: 'dataset',
- header: t('list.columns.dataset'),
+ enableSorting: false,
+ header: ({ column }) => (
+
+ ),
cell: ({ row }) =>
{getDatasetDisplayName(row.original.dataset)},
},
{
accessorKey: 'createdAt',
- header: t('list.columns.createdAt'),
+ enableSorting: false,
+ header: ({ column }) => (
+
+ ),
cell: ({ row }) =>
{new Intl.DateTimeFormat('de-DE').format(new Date(row.original.createdAt))},
},
],
diff --git a/src/components/transactions/bank-import-client.tsx b/src/components/transactions/bank-import-client.tsx
index 5f00d583..c46d5ead 100644
--- a/src/components/transactions/bank-import-client.tsx
+++ b/src/components/transactions/bank-import-client.tsx
@@ -19,6 +19,7 @@ import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import type { BulkAction } from '@/components/ui/data-table';
import { DataTable } from '@/components/ui/data-table';
+import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useRouter } from '@/i18n/navigation';
@@ -201,12 +202,14 @@ export function BankImportClient({ projectId, accounts, initialRows, initialAcco
() => [
{
accessorKey: 'bookingDate',
- header: t('columns.date'),
+ enableSorting: false,
+ header: ({ column }) =>
,
cell: ({ row }) => format.dateTime(row.original.bookingDate, { dateStyle: 'medium' }),
},
{
id: 'counterparty',
- header: t('columns.counterparty'),
+ enableSorting: false,
+ header: ({ column }) =>
,
cell: ({ row }) => (
{row.original.counterpartyName ?? '—'}
@@ -218,14 +221,22 @@ export function BankImportClient({ projectId, accounts, initialRows, initialAcco
},
{
accessorKey: 'remittanceInfo',
- header: t('columns.remittance'),
+ enableSorting: false,
+ header: ({ column }) => (
+
+ ),
cell: ({ row }) => (
{row.original.remittanceInfo ?? '—'}
),
},
{
accessorKey: 'amount',
- header: t('columns.amount'),
+ enableSorting: false,
+ header: ({ column }) =>
,
cell: ({ row }) => (
= 0 ? 'text-success-foreground' : 'text-destructive'}>
{formatCurrency(row.original.amount)} {row.original.currency}
@@ -234,7 +245,10 @@ export function BankImportClient({ projectId, accounts, initialRows, initialAcco
},
{
id: 'lender',
- header: t('columns.lender'),
+ enableSorting: false,
+ header: ({ column }) => (
+
+ ),
cell: ({ row }) => (
,
cell: ({ row }) => {
const lenderId = row.original.selectedLenderId;
const lenderLoans = lenderId ? (loansByLenderId.get(lenderId) ?? EMPTY_LOANS) : EMPTY_LOANS;
@@ -266,7 +281,8 @@ export function BankImportClient({ projectId, accounts, initialRows, initialAcco
},
{
id: 'type',
- header: t('columns.type'),
+ enableSorting: false,
+ header: ({ column }) => ,
cell: ({ row }) => {
if (row.original.amount > 0) {
return {commonT('enums.transaction.type.DEPOSIT')};
diff --git a/src/components/ui/data-table-body.tsx b/src/components/ui/data-table-body.tsx
index 3654f775..277e13da 100644
--- a/src/components/ui/data-table-body.tsx
+++ b/src/components/ui/data-table-body.tsx
@@ -47,13 +47,15 @@ export function DataTableBody({
{table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => {
+ const textAlign = header.column.columnDef.meta?.style?.textAlign ?? 'left';
return (
{
+ const handleFilterChange = (
+ columnId: string,
+ value: unknown,
+ type: ColumnFilterConfig['type'],
+ defaultOperator?: NumberFilterOperator,
+ ) => {
const filters = activeFilters.filter((filter) => filter.id !== columnId);
- if (!isEmptyFilterValue(value, type)) {
+ if (!isEmptyFilterValue(value, type, defaultOperator)) {
filters.push({ id: columnId, value });
}
@@ -146,8 +159,9 @@ export function DataTableColumnFilters({
{
- handleFilterChange(columnId, value, 'number');
+ handleFilterChange(columnId, value, 'number', filterConfig.defaultOperator);
}}
/>
);
diff --git a/src/components/ui/data-table-column-filters/number-filter.tsx b/src/components/ui/data-table-column-filters/number-filter.tsx
index d70f92e1..21280fae 100644
--- a/src/components/ui/data-table-column-filters/number-filter.tsx
+++ b/src/components/ui/data-table-column-filters/number-filter.tsx
@@ -2,11 +2,13 @@ import type { ColumnFilter } from '@tanstack/react-table';
import type { FilterFieldSize, FilterFieldVariant } from '@/components/filters/filter-field-group';
import { NumberFilterWithOperator } from '@/components/filters/number-filter-with-operator';
+import type { NumberFilterOperator } from '@/types/number-filter-value';
interface NumberFilterProps {
filterState?: ColumnFilter;
onFilterChange: (value: unknown) => void;
allowEmpty?: boolean;
+ defaultOperator?: NumberFilterOperator;
variant?: FilterFieldVariant;
size?: FilterFieldSize;
}
@@ -15,6 +17,7 @@ export function NumberFilter({
filterState,
onFilterChange,
allowEmpty = false,
+ defaultOperator = 'between',
variant = 'row',
size = 'default',
}: NumberFilterProps) {
@@ -23,6 +26,7 @@ export function NumberFilter({
value={filterState?.value}
onChange={onFilterChange}
allowEmpty={allowEmpty}
+ defaultOperator={defaultOperator}
translationNamespace="dataTable"
variant={variant}
size={size}
diff --git a/src/components/ui/data-table-column-filters/text-filter.tsx b/src/components/ui/data-table-column-filters/text-filter.tsx
index 0d747d9c..87782b5d 100644
--- a/src/components/ui/data-table-column-filters/text-filter.tsx
+++ b/src/components/ui/data-table-column-filters/text-filter.tsx
@@ -30,7 +30,7 @@ export function TextFilter({
translationNamespace="dataTable"
variant={variant}
size={size}
- placeholder={`Filter ${label || columnId}...`}
+ placeholder={`${label || columnId}...`}
/>
);
}
diff --git a/src/components/ui/data-table-column-header.tsx b/src/components/ui/data-table-column-header.tsx
index 1d3500c8..d029cf74 100644
--- a/src/components/ui/data-table-column-header.tsx
+++ b/src/components/ui/data-table-column-header.tsx
@@ -1,32 +1,205 @@
+'use client';
+
import type { Column } from '@tanstack/react-table';
-import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
+import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, ChevronDown, MoreHorizontal } from 'lucide-react';
+import { useTranslations } from 'next-intl';
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
import { Button } from '@/components/ui/button';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { Switch } from '@/components/ui/switch';
+import { useFilterPresence } from '@/components/ui/data-table-filter-presence-context';
+import { cn } from '@/lib/utils';
+
+/** Set to true when column reordering via header arrows is implemented. */
+const SHOW_MOVE_COLUMN_BUTTONS = false;
+/** Set to true when the column more-options menu is implemented. */
+const SHOW_MORE_OPTIONS_BUTTON = false;
interface DataTableColumnHeaderProps extends React.HTMLAttributes {
column: Column;
+ /** Short name shown in the table header. */
title: string;
+ /** Long name shown in the column menu. Defaults to `title`. */
+ longTitle?: string;
+ /** Optional explanation shown under the long name. */
+ description?: string;
}
+type MoveButtonCoords = {
+ top: number;
+ left: number;
+ right: number;
+};
+
export function DataTableColumnHeader({
column,
title,
+ longTitle,
+ description,
className,
}: DataTableColumnHeaderProps) {
- if (!column.getCanSort()) {
- return {title}
;
- }
+ const t = useTranslations('dataTable.columnMenu');
+ const [open, setOpen] = useState(false);
+ const [coords, setCoords] = useState(null);
+ const triggerRef = useRef(null);
+ const canSort = column.getCanSort();
+ const sorted = column.getIsSorted();
+ const resolvedLongTitle = longTitle ?? title;
+ const showMoveButtons = SHOW_MOVE_COLUMN_BUTTONS && open;
+ const filterPresence = useFilterPresence();
+ const canFilter = !!filterPresence?.config[column.id];
+ const filterPresent = canFilter ? filterPresence.isPresent(column.id) : false;
+ const textAlign = column.columnDef.meta?.style?.textAlign ?? 'left';
+ const justifyClass =
+ textAlign === 'right' ? 'justify-end' : textAlign === 'center' ? 'justify-center' : 'justify-start';
+
+ useLayoutEffect(() => {
+ if (!showMoveButtons) {
+ setCoords(null);
+ return;
+ }
+
+ const updateCoords = () => {
+ const rect = triggerRef.current?.getBoundingClientRect();
+ if (!rect) return;
+ setCoords({
+ top: rect.top + rect.height / 2,
+ left: rect.left,
+ right: rect.right,
+ });
+ };
+
+ updateCoords();
+ window.addEventListener('scroll', updateCoords, true);
+ window.addEventListener('resize', updateCoords);
+ return () => {
+ window.removeEventListener('scroll', updateCoords, true);
+ window.removeEventListener('resize', updateCoords);
+ };
+ }, [showMoveButtons]);
+
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => {
+ setMounted(true);
+ }, []);
return (
-
+
+
+
+
+
+
+
+
+
{resolvedLongTitle}
+ {description ?
{description}
: null}
+
+ {SHOW_MORE_OPTIONS_BUTTON ? (
+
+ ) : null}
+
+
+ {canSort ? (
+
+
{t('sort')}
+
+
+
+
+
+ ) : null}
+
+ {canFilter ? (
+
+ {t('filter')}
+ {
+ filterPresence?.setPresent(column.id, checked);
+ }}
+ aria-label={t('filter')}
+ />
+
+ ) : null}
+
+
+
+ {mounted && showMoveButtons && coords
+ ? createPortal(
+ <>
+
+
+ >,
+ document.body,
+ )
+ : null}
+
);
}
diff --git a/src/components/ui/data-table-filter-chip.tsx b/src/components/ui/data-table-filter-chip.tsx
new file mode 100644
index 00000000..57cc813f
--- /dev/null
+++ b/src/components/ui/data-table-filter-chip.tsx
@@ -0,0 +1,495 @@
+'use client';
+
+import type { ViewType } from '@prisma/client';
+import type { Table } from '@tanstack/react-table';
+import { X } from 'lucide-react';
+import { useTranslations } from 'next-intl';
+import { type KeyboardEvent, useEffect, useMemo, useRef } from 'react';
+
+import { FilterFieldGroup, filterOperatorSegmentClass } from '@/components/filters/filter-field-group';
+import {
+ BooleanFilter,
+ DateFilter,
+ MultiSelectFilter,
+ NumberFilter,
+ SelectFilter,
+ TextFilter,
+} from '@/components/ui/data-table-column-filters/index';
+import { Input } from '@/components/ui/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import type { DataTableColumnFilters } from '@/components/ui/data-table';
+import type { SetTableUrlState, TableUrlState } from '@/lib/hooks/use-table-url-state';
+import {
+ FILTER_REMOVE_VALUE,
+ GLOBAL_SEARCH_ALL,
+ createDefaultFilterValue,
+ getAvailableFilterTargets,
+ getVisibleFilterColumnIds,
+ removePresentFilter,
+ resolveColumnFilterLabel,
+ retargetPresentFilter,
+ upsertPresentFilterValue,
+} from '@/lib/table-filter-presence';
+import { cn, NumberParser } from '@/lib/utils';
+
+const EURO_SHORTCUT_COLUMN_IDS = ['amount', 'loan.amount', 'transaction.amount'] as const;
+
+const deNumberParser = new NumberParser('de-DE');
+
+interface DataTableFilterChipProps {
+ table: Table;
+ columnFilters: DataTableColumnFilters;
+ tableState: TableUrlState;
+ setTableState: SetTableUrlState;
+ /** Quick search chip (Alle allowed, not removable) or a present column filter. */
+ mode: 'quickSearch' | 'column';
+ /** Column id for column-mode chips. */
+ columnId?: string;
+ viewType?: ViewType;
+ onRowClick?: (row: TData) => void;
+}
+
+function parseShortcutRemainderNumber(remainder: string): number | null {
+ const trimmed = remainder.trim();
+ if (!trimmed) {
+ return null;
+ }
+ const stripped = deNumberParser.strip(trimmed);
+ if (!stripped) {
+ return null;
+ }
+ const parsed = deNumberParser.parse(stripped);
+ return parsed != null && Number.isFinite(parsed) ? parsed : null;
+}
+
+function findShortcutColumnId(availableIds: Set, candidates: readonly string[]): string | null {
+ for (const id of candidates) {
+ if (availableIds.has(id)) {
+ return id;
+ }
+ }
+ return null;
+}
+
+function getHashShortcutCandidates(viewType?: ViewType): readonly string[] {
+ switch (viewType) {
+ case 'LOAN':
+ return ['loanNumber'];
+ case 'LENDER':
+ return ['lenderNumber'];
+ default:
+ return [];
+ }
+}
+
+function formatFieldLabel(label: string, shortcut: '#' | '€' | null): string {
+ return shortcut ? `${label} (${shortcut})` : label;
+}
+
+export function DataTableFilterChip({
+ table,
+ columnFilters,
+ tableState,
+ setTableState,
+ mode,
+ columnId,
+ viewType,
+ onRowClick,
+}: DataTableFilterChipProps) {
+ const t = useTranslations('dataTable');
+ const filterContainerRef = useRef(null);
+ const shouldFocusFilterInputRef = useRef(false);
+
+ const isQuickSearch = mode === 'quickSearch';
+ const selectedField = isQuickSearch ? tableState.quickSearchField || GLOBAL_SEARCH_ALL : (columnId ?? '');
+
+ const visibleFilterColumnIds = useMemo(
+ () => getVisibleFilterColumnIds(table, columnFilters, tableState.columnVisibility),
+ [table, columnFilters, tableState.columnVisibility],
+ );
+
+ const availableFieldIds = useMemo(() => new Set(visibleFilterColumnIds), [visibleFilterColumnIds]);
+
+ const selectableTargets = useMemo(() => {
+ const currentId = selectedField === GLOBAL_SEARCH_ALL ? undefined : selectedField;
+ return getAvailableFilterTargets(table, columnFilters, tableState, currentId);
+ }, [table, columnFilters, tableState, selectedField]);
+
+ const hashShortcutColumnId = useMemo(
+ () => findShortcutColumnId(availableFieldIds, getHashShortcutCandidates(viewType)),
+ [availableFieldIds, viewType],
+ );
+
+ const euroShortcutColumnId = useMemo(
+ () => findShortcutColumnId(availableFieldIds, EURO_SHORTCUT_COLUMN_IDS),
+ [availableFieldIds],
+ );
+
+ const fieldOptions = useMemo(() => {
+ const idsForOptions = isQuickSearch
+ ? // QS can pick Alle or any free target (plus its current column)
+ [selectedField !== GLOBAL_SEARCH_ALL ? selectedField : null, ...selectableTargets].filter(
+ (id): id is string => !!id && availableFieldIds.has(id),
+ )
+ : // Column chip: current + available; dedupe
+ [selectedField, ...selectableTargets.filter((id) => id !== selectedField)].filter(
+ (id) => availableFieldIds.has(id) || id === selectedField,
+ );
+
+ const uniqueIds = [...new Set(idsForOptions)];
+
+ return uniqueIds.map((id) => {
+ const baseLabel = resolveColumnFilterLabel(table, id, columnFilters);
+ const shortcut = id === hashShortcutColumnId ? '#' : id === euroShortcutColumnId ? '€' : null;
+ return {
+ id,
+ label: formatFieldLabel(baseLabel, isQuickSearch ? shortcut : null),
+ };
+ });
+ }, [
+ isQuickSearch,
+ selectedField,
+ selectableTargets,
+ availableFieldIds,
+ table,
+ columnFilters,
+ hashShortcutColumnId,
+ euroShortcutColumnId,
+ ]);
+
+ const selectedConfig =
+ selectedField === GLOBAL_SEARCH_ALL ? null : columnFilters[selectedField] ?? null;
+ const selectedFilterState = tableState.columnFilters.find((filter) => filter.id === selectedField);
+
+ // If QS target column was hidden / removed from config, reset to Alle.
+ useEffect(() => {
+ if (!isQuickSearch) return;
+ if (selectedField !== GLOBAL_SEARCH_ALL && !availableFieldIds.has(selectedField)) {
+ setTableState({ quickSearchField: '', globalFilter: '' });
+ }
+ }, [isQuickSearch, selectedField, availableFieldIds, setTableState]);
+
+ // Ensure a present default entry when a typed target has no stored value yet.
+ useEffect(() => {
+ if (!selectedConfig || selectedField === GLOBAL_SEARCH_ALL) return;
+ if (selectedFilterState) return;
+ setTableState({
+ columnFilters: upsertPresentFilterValue(
+ tableState.columnFilters,
+ selectedField,
+ createDefaultFilterValue(selectedConfig),
+ ),
+ ...(isQuickSearch ? { globalFilter: '' } : {}),
+ });
+ }, [
+ selectedConfig,
+ selectedField,
+ selectedFilterState,
+ isQuickSearch,
+ setTableState,
+ tableState.columnFilters,
+ ]);
+
+ useEffect(() => {
+ if (!shouldFocusFilterInputRef.current) return;
+ if (isQuickSearch && selectedField === GLOBAL_SEARCH_ALL) return;
+ const frame = requestAnimationFrame(() => {
+ if (!shouldFocusFilterInputRef.current) return;
+ shouldFocusFilterInputRef.current = false;
+ const input = filterContainerRef.current?.querySelector(
+ 'input:not([type="hidden"]):not([role="combobox"])',
+ );
+ input?.focus();
+ input?.select();
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [isQuickSearch, selectedField]);
+
+ const setColumnFilterValue = (targetColumnId: string, value: unknown, extra?: Partial) => {
+ setTableState({
+ columnFilters: upsertPresentFilterValue(tableState.columnFilters, targetColumnId, value),
+ ...extra,
+ });
+ };
+
+ const applyShortcut = (targetColumnId: string, remainder: string) => {
+ const config = columnFilters[targetColumnId];
+ if (!config) return;
+
+ shouldFocusFilterInputRef.current = true;
+
+ // Drop any existing present chip for this column — QS takes ownership.
+ const withoutTarget = removePresentFilter(tableState.columnFilters, targetColumnId);
+
+ if (config.type === 'number') {
+ const numericValue = parseShortcutRemainderNumber(remainder);
+ if (numericValue == null) {
+ setTableState({
+ quickSearchField: targetColumnId,
+ globalFilter: '',
+ columnFilters: withoutTarget,
+ });
+ return;
+ }
+ setTableState({
+ quickSearchField: targetColumnId,
+ globalFilter: '',
+ columnFilters: upsertPresentFilterValue(withoutTarget, targetColumnId, {
+ operator: 'eq',
+ value: numericValue,
+ }),
+ });
+ return;
+ }
+
+ if (config.type === 'text') {
+ const textValue = remainder.trim();
+ if (!textValue) {
+ setTableState({
+ quickSearchField: targetColumnId,
+ globalFilter: '',
+ columnFilters: withoutTarget,
+ });
+ return;
+ }
+ setTableState({
+ quickSearchField: targetColumnId,
+ globalFilter: '',
+ columnFilters: upsertPresentFilterValue(withoutTarget, targetColumnId, {
+ operator: 'contains',
+ value: textValue,
+ }),
+ });
+ return;
+ }
+
+ setTableState({
+ quickSearchField: targetColumnId,
+ globalFilter: '',
+ columnFilters: withoutTarget,
+ });
+ };
+
+ const handleGlobalFilterChange = (rawValue: string) => {
+ if (hashShortcutColumnId && rawValue.startsWith('#')) {
+ applyShortcut(hashShortcutColumnId, rawValue.slice(1));
+ return;
+ }
+ if (euroShortcutColumnId && rawValue.startsWith('€')) {
+ applyShortcut(euroShortcutColumnId, rawValue.slice(1));
+ return;
+ }
+ setTableState({ globalFilter: rawValue });
+ };
+
+ const handleQuickSearchFieldChange = (nextField: string) => {
+ const previousField = selectedField;
+ let nextFilters = tableState.columnFilters;
+
+ if (previousField !== GLOBAL_SEARCH_ALL) {
+ nextFilters = removePresentFilter(nextFilters, previousField);
+ }
+
+ if (nextField === GLOBAL_SEARCH_ALL) {
+ setTableState({
+ quickSearchField: '',
+ columnFilters: nextFilters,
+ globalFilter: '',
+ });
+ return;
+ }
+
+ // Taking a column that was a present chip: remove that chip entry first, QS owns it.
+ nextFilters = removePresentFilter(nextFilters, nextField);
+
+ setTableState({
+ quickSearchField: nextField,
+ columnFilters: nextFilters,
+ globalFilter: '',
+ });
+ };
+
+ const handleColumnTargetChange = (nextField: string) => {
+ if (!columnId) return;
+
+ if (nextField === FILTER_REMOVE_VALUE) {
+ setTableState({
+ columnFilters: removePresentFilter(tableState.columnFilters, columnId),
+ });
+ return;
+ }
+
+ if (nextField === columnId) return;
+
+ const toConfig = columnFilters[nextField];
+ if (!toConfig) return;
+
+ setTableState({
+ columnFilters: retargetPresentFilter(tableState.columnFilters, columnId, nextField, toConfig),
+ });
+ };
+
+ const handleTargetChange = (nextField: string) => {
+ if (isQuickSearch) {
+ handleQuickSearchFieldChange(nextField);
+ return;
+ }
+ handleColumnTargetChange(nextField);
+ };
+
+ const handleEnterOpen = (event: KeyboardEvent) => {
+ if (event.key !== 'Enter' || !onRowClick) return;
+ const target = event.target;
+ if (
+ target instanceof HTMLElement &&
+ (target.closest('[role="listbox"]') || target.closest('[data-radix-select-content]'))
+ ) {
+ return;
+ }
+ const filteredRows = table.getFilteredRowModel().rows;
+ if (filteredRows.length !== 1) return;
+ event.preventDefault();
+ onRowClick(filteredRows[0].original);
+ };
+
+ const sharedSegmentClass = cn(
+ 'shadow-none',
+ isQuickSearch ? 'bg-card dark:bg-card' : 'bg-transparent dark:bg-input/30',
+ );
+ const quickSearchSurfaceClass =
+ '[&_input]:bg-card dark:[&_input]:bg-card [&_button]:bg-card dark:[&_button]:bg-card';
+
+ const selectValue = selectedField || GLOBAL_SEARCH_ALL;
+
+ const fieldSelect = (
+
+ );
+
+ if (!selectedConfig || selectedField === GLOBAL_SEARCH_ALL) {
+ return (
+
+
+ {fieldSelect}
+ {isQuickSearch ? (
+ {
+ handleGlobalFilterChange(event.target.value);
+ }}
+ className={cn('max-w-52 shrink-0', sharedSegmentClass)}
+ />
+ ) : null}
+
+
+ );
+ }
+
+ const filterControl = (() => {
+ const onChange = (value: unknown) => {
+ if (isQuickSearch) {
+ setColumnFilterValue(selectedField, value, { globalFilter: '' });
+ return;
+ }
+ setColumnFilterValue(selectedField, value);
+ };
+
+ switch (selectedConfig.type) {
+ case 'boolean':
+ return ;
+ case 'select':
+ return (
+
+ );
+ case 'multi-select':
+ return (
+
+ );
+ case 'number':
+ return (
+
+ );
+ case 'date':
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+ })();
+
+ return (
+ :first-child]:rounded-r-none',
+ '[&>:last-child>*:first-child]:rounded-l-none [&>:last-child>*:first-child]:border-l-0',
+ '[&>:last-child:not(fieldset)]:rounded-l-none [&>:last-child:not(fieldset)]:border-l-0',
+ '[&_fieldset]:w-auto',
+ isQuickSearch && quickSearchSurfaceClass,
+ )}
+ >
+ {fieldSelect}
+ {filterControl}
+
+ );
+}
diff --git a/src/components/ui/data-table-filter-presence-context.tsx b/src/components/ui/data-table-filter-presence-context.tsx
new file mode 100644
index 00000000..b807f9fa
--- /dev/null
+++ b/src/components/ui/data-table-filter-presence-context.tsx
@@ -0,0 +1,21 @@
+'use client';
+
+import { createContext, useContext } from 'react';
+
+import type { FilterPresenceApi } from '@/lib/table-filter-presence';
+
+const FilterPresenceContext = createContext(null);
+
+export function FilterPresenceProvider({
+ value,
+ children,
+}: {
+ value: FilterPresenceApi | null;
+ children: React.ReactNode;
+}) {
+ return {children};
+}
+
+export function useFilterPresence(): FilterPresenceApi | null {
+ return useContext(FilterPresenceContext);
+}
diff --git a/src/components/ui/data-table-header.tsx b/src/components/ui/data-table-header.tsx
index 8be131bc..aa6a4442 100644
--- a/src/components/ui/data-table-header.tsx
+++ b/src/components/ui/data-table-header.tsx
@@ -4,7 +4,7 @@ import type { View, ViewType } from '@prisma/client';
import { useQueryClient } from '@tanstack/react-query';
import type { Table, VisibilityState } from '@tanstack/react-table';
import { isEqual } from 'lodash';
-import { ChevronDown, FileDown, Save, Search, SlidersHorizontal } from 'lucide-react';
+import { ChevronDown, FileDown, Plus, Save, Search, SlidersHorizontal, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useAction } from 'next-safe-action/hooks';
import { type ReactNode, useMemo, useState } from 'react';
@@ -25,8 +25,15 @@ import { Input } from '@/components/ui/input';
import { useRouter } from '@/i18n/navigation';
import { useProjectId } from '@/lib/hooks/use-project-id';
import type { SetTableUrlState, TableUrlState } from '@/lib/hooks/use-table-url-state';
-import { DataTableColumnFilters } from './data-table-column-filters';
+import {
+ addPresentFilter,
+ countActiveFilters,
+ getNextAvailableFilterTarget,
+ getPresentColumnFilterIds,
+} from '@/lib/table-filter-presence';
+import type { NumberFilterOperator } from '@/types/number-filter-value';
import { DataTableExportDialog } from './data-table-export-dialog';
+import { DataTableFilterChip } from './data-table-filter-chip';
import { SaveViewDialog } from './save-view-dialog';
import { ViewManager } from './view-manager';
@@ -36,6 +43,7 @@ type ColumnFiltersConfig = {
options?: { label: string; value: string }[];
label?: string;
allowEmpty?: boolean;
+ defaultOperator?: NumberFilterOperator;
};
};
@@ -46,7 +54,6 @@ interface DataTableHeaderProps {
columnFilters?: ColumnFiltersConfig;
viewType?: ViewType;
views: View[];
- hasActiveFilters: () => boolean;
defaultColumnVisibility: VisibilityState;
tableState: TableUrlState;
setTableState: SetTableUrlState;
@@ -58,6 +65,7 @@ interface DataTableHeaderProps {
extraViewData?: Record;
isExtraViewDataDirty?: (savedData: Record | undefined) => boolean;
toolbarContent?: ReactNode;
+ onRowClick?: (row: TData) => void;
}
export function DataTableHeader({
@@ -68,7 +76,6 @@ export function DataTableHeader({
defaultColumnVisibility,
viewType,
views,
- hasActiveFilters,
tableState,
setTableState,
allowSidebarViews = false,
@@ -78,11 +85,11 @@ export function DataTableHeader({
toolbarExtra,
extraViewData,
isExtraViewDataDirty,
+ onRowClick,
}: DataTableHeaderProps) {
const projectId = useProjectId();
const router = useRouter();
const t = useTranslations('dataTable');
- const [showColumnFilters, setShowColumnFilters] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveAsOpen, setSaveAsOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
@@ -93,11 +100,39 @@ export function DataTableHeader({
const { executeAsync: executeUpdateView } = useAction(updateViewAction);
const { executeAsync: executeDeleteView } = useAction(deleteViewAction);
+ const hasColumnFilterConfig = Object.keys(columnFilters).length > 0;
+
+ const presentColumnFilterIds = useMemo(
+ () => getPresentColumnFilterIds(tableState, columnFilters, tableState.columnVisibility),
+ [tableState, columnFilters],
+ );
+
+ const activeFilterCount = useMemo(
+ () => countActiveFilters(tableState, columnFilters),
+ [tableState, columnFilters],
+ );
+
+ const nextAvailableTarget = useMemo(
+ () => (hasColumnFilterConfig ? getNextAvailableFilterTarget(table, columnFilters, tableState) : null),
+ [hasColumnFilterConfig, table, columnFilters, tableState],
+ );
+
+ const handleAddFilter = () => {
+ if (!nextAvailableTarget) return;
+ const config = columnFilters[nextAvailableTarget];
+ if (!config) return;
+ setTableState({
+ columnFilters: addPresentFilter(tableState.columnFilters, nextAvailableTarget, config),
+ filtersExpanded: true,
+ });
+ };
+
const buildViewDataPayload = () => ({
sorting: tableState.sorting,
columnFilters: tableState.columnFilters,
columnVisibility: tableState.columnVisibility,
globalFilter: tableState.globalFilter,
+ filtersExpanded: tableState.filtersExpanded,
pageSize: tableState.pageSize,
pagination: {
pageIndex: 0,
@@ -203,29 +238,65 @@ export function DataTableHeader({
!isEqual(tableState.columnVisibility, viewData.columnVisibility ?? defaultColumnVisibility) ||
!isEqual(tableState.sorting, viewData.sorting ?? []) ||
!isEqual(tableState.columnFilters, viewData.columnFilters ?? []) ||
+ tableState.filtersExpanded !== (viewData.filtersExpanded ?? false) ||
(isExtraViewDataDirty?.(viewData) ?? false)
);
}, [views, tableState, defaultColumnVisibility, isExtraViewDataDirty]);
return (
<>
-
- {(showFilter || toolbarExtra) && (
-
- {showFilter && (
+
+
+ {showFilter &&
+ (hasColumnFilterConfig ? (
+ <>
+
+
+ >
+ ) : (
{
setTableState({ globalFilter: event.target.value });
}}
+ onKeyDown={(event) => {
+ if (event.key !== 'Enter' || !onRowClick) return;
+ const filteredRows = table.getFilteredRowModel().rows;
+ if (filteredRows.length !== 1) return;
+ event.preventDefault();
+ onRowClick(filteredRows[0].original);
+ }}
className="max-w-60 shrink-0 bg-background dark:bg-background"
/>
- )}
- {toolbarExtra}
-
- )}
-
+ ))}
+
+
{toolbarExtra}
+
{viewType && (
<>
({
sorting: [],
columnFilters: [],
globalFilter: '',
+ quickSearchField: '',
+ filtersExpanded: false,
pageIndex: 0,
pageSize: 25,
viewName: '',
@@ -256,6 +329,7 @@ export function DataTableHeader({
sorting,
columnFilters,
globalFilter,
+ filtersExpanded,
pageSize,
pagination,
// biome-ignore lint/suspicious/noExplicitAny: view data is stored as JSON
@@ -268,6 +342,8 @@ export function DataTableHeader({
sorting: sorting ?? [],
columnFilters: columnFilters ?? [],
globalFilter: globalFilter ?? '',
+ quickSearchField: '',
+ filtersExpanded: filtersExpanded ?? false,
pageIndex: 0,
pageSize: pagination?.pageSize ?? pageSize ?? 25,
});
@@ -313,18 +389,6 @@ export function DataTableHeader({
/>
>
)}
- {Object.keys(columnFilters).length > 0 && (
-
- )}
{showExport && (
<>
- {showColumnFilters && Object.keys(columnFilters).length > 0 && (
-
- )}
+ {showFilter && hasColumnFilterConfig && tableState.filtersExpanded ? (
+
+
+ {presentColumnFilterIds.map((id) => (
+
+ ))}
+
+
+
+
+ ) : null}
>
);
}
@@ -367,8 +471,11 @@ function ColumnVisibilityMenu
({ table, columnFilters }: ColumnVisibilityM
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
- const resolveColumnLabel = (columnId: string, exportLabel?: string) =>
- columnFilters[columnId]?.label ?? exportLabel ?? columnId;
+ const resolveColumnLabel = (column: ReturnType['getAllColumns']>[number]) =>
+ columnFilters[column.id]?.label ??
+ column.columnDef.meta?.labelLong ??
+ column.columnDef.meta?.export?.label ??
+ column.id;
const groupedHideableColumns = useMemo(() => {
const hideableColumns = table.getAllColumns().filter((column) => column.getCanHide());
@@ -403,7 +510,10 @@ function ColumnVisibilityMenu({ table, columnFilters }: ColumnVisibilityM
const matches = (column: (typeof groupedHideableColumns.flatColumns)[number]) => {
const label =
- columnFilters[column.id]?.label ?? column.columnDef.meta?.export?.label ?? column.id;
+ columnFilters[column.id]?.label ??
+ column.columnDef.meta?.labelLong ??
+ column.columnDef.meta?.export?.label ??
+ column.id;
return label.toLowerCase().includes(query);
};
@@ -453,7 +563,7 @@ function ColumnVisibilityMenu({ table, columnFilters }: ColumnVisibilityM
onCheckedChange={(value) => column.toggleVisibility(!!value)}
onSelect={(event) => event.preventDefault()}
>
- {resolveColumnLabel(column.id, column.columnDef.meta?.export?.label)}
+ {resolveColumnLabel(column)}
))}
{filteredColumns.flatColumns.length > 0 && filteredColumns.groupedColumns.length > 0 && (
@@ -469,7 +579,7 @@ function ColumnVisibilityMenu({ table, columnFilters }: ColumnVisibilityM
onCheckedChange={(value) => column.toggleVisibility(!!value)}
onSelect={(event) => event.preventDefault()}
>
- {resolveColumnLabel(column.id, column.columnDef.meta?.export?.label)}
+ {resolveColumnLabel(column)}
))}
{groupIndex < filteredColumns.groupedColumns.length - 1 && }
diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx
index a6340871..62be4a77 100644
--- a/src/components/ui/data-table.tsx
+++ b/src/components/ui/data-table.tsx
@@ -18,14 +18,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { type SetTableUrlState, type TableUrlState, useTableUrlState } from '@/lib/hooks/use-table-url-state';
import {
matchesDateFilter,
+ matchesGlobalFilter,
matchesNumberRangeFilter,
matchesTextFilter,
} from '@/lib/entity-filters/filter-matchers';
+import {
+ addPresentFilter,
+ cleanupFiltersForHiddenColumns,
+ type FilterPresenceApi,
+ isFilterPresent,
+ removePresentFilter,
+} from '@/lib/table-filter-presence';
import { cn } from '@/lib/utils';
+import type { NumberFilterOperator } from '@/types/number-filter-value';
import { Checkbox } from './checkbox';
import { DataTableBody } from './data-table-body';
import { DataTableBulkBar } from './data-table-bulk-bar';
+import { FilterPresenceProvider } from './data-table-filter-presence-context';
import { DataTableHeader } from './data-table-header';
import { DataTablePagination } from './data-table-pagination';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from './dropdown-menu';
@@ -65,6 +75,12 @@ declare module '@tanstack/react-table' {
bulkSelectColumn?: boolean;
export?: ColumnExportMeta;
columnGroup?: ColumnGroupMeta;
+ /** Short label shown in the table header. */
+ labelShort?: string;
+ /** Long label shown in the column menu, filters, and export. */
+ labelLong?: string;
+ /** Optional explanation shown in the column menu. */
+ description?: string;
}
}
@@ -90,6 +106,7 @@ export type DataTableColumnFilters = {
options?: { label: string; value: string }[];
label?: string;
allowEmpty?: boolean;
+ defaultOperator?: NumberFilterOperator;
};
};
@@ -216,22 +233,35 @@ export function DataTable({
}
}, [data, isRowSelectionControlled]);
- // Function to check if any filters are active
- const hasActiveFilters = () => {
- // Check if any column filters are active
- const hasColumnFilters = columnFilterState.some((filter) => {
- const value = filter.value;
- if (Array.isArray(value)) {
- // For range filters (number, date)
- return value.some((v) => v !== undefined && v !== '');
- }
- // For text and select filters
- return value !== undefined && value !== '';
- });
-
- // Only return true if column filters are active, ignoring global filter
- return hasColumnFilters;
- };
+ const filterPresence = useMemo(
+ () => ({
+ config: columnFilters,
+ isPresent: (columnId) => isFilterPresent(columnId, tableState),
+ setPresent: (columnId, present) => {
+ const config = columnFilters[columnId];
+ if (!config) return;
+
+ if (present) {
+ if (isFilterPresent(columnId, tableState)) return;
+ setTableState({
+ columnFilters: addPresentFilter(tableState.columnFilters, columnId, config),
+ filtersExpanded: true,
+ });
+ return;
+ }
+
+ const next: Partial = {
+ columnFilters: removePresentFilter(tableState.columnFilters, columnId),
+ };
+ if (tableState.quickSearchField === columnId) {
+ next.quickSearchField = '';
+ next.globalFilter = '';
+ }
+ setTableState(next);
+ },
+ }),
+ [columnFilters, tableState, setTableState],
+ );
const hasBulkActions = !!bulkActions?.length;
@@ -362,7 +392,16 @@ export function DataTable({
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: (updater) => {
const next = updater instanceof Function ? updater(columnVisibility) : updater;
- setTableState({ columnVisibility: next });
+ const cleanup = cleanupFiltersForHiddenColumns({
+ nextVisibility: next,
+ columnFilters: columnFilterState,
+ quickSearchField: tableState.quickSearchField,
+ filterConfig: columnFilters,
+ });
+ setTableState({
+ columnVisibility: next,
+ ...(cleanup ?? {}),
+ });
},
onGlobalFilterChange: (updater) => {
const next = updater instanceof Function ? updater(globalFilter) : updater;
@@ -395,16 +434,30 @@ export function DataTable({
dateRange: dateRangeFilter,
inNumberRange: inNumberRangeFilter,
},
- // Add global filter function to search across all columns
+ // Add global filter function to search across all columns.
+ // A leading `#` scopes the search to loan/lender number only (display uses `#`,
+ // but accessors stay numeric so column filters are unaffected).
globalFilterFn: (row, columnId, filterValue) => {
- const value = row.getValue(columnId);
- if (!value) return false;
-
- // Convert both the value and filter to lowercase for case-insensitive search
- const searchValue = String(value).toLowerCase();
- const searchFilter = String(filterValue).toLowerCase();
+ const rawFilter = String(filterValue).trim();
+ const isHashSearch = rawFilter.startsWith('#');
+
+ if (isHashSearch) {
+ const hashTargetColumnId =
+ viewType === 'LOAN' ? 'loanNumber' : viewType === 'LENDER' ? 'lenderNumber' : null;
+ if (!hashTargetColumnId || columnId !== hashTargetColumnId) {
+ return false;
+ }
+
+ const value = row.getValue(columnId);
+ const searchFilter = rawFilter.slice(1).trim();
+ if (!searchFilter) {
+ return value != null && value !== '';
+ }
+
+ return matchesGlobalFilter(value, searchFilter);
+ }
- return searchValue.includes(searchFilter);
+ return matchesGlobalFilter(row.getValue(columnId), rawFilter);
},
// Enable global filtering for all columns
enableGlobalFilter: true,
@@ -426,59 +479,61 @@ export function DataTable({
}
return (
-
- {!hideHeader && (
-
-
- table={table}
- showColumnVisibility={showColumnVisibility}
- showFilter={showFilter}
- columnFilters={columnFilters}
- defaultColumnVisibility={defaultColumnVisibility ?? EMPTY_COLUMN_VISIBILITY}
- views={views || []}
- viewType={viewType}
- hasActiveFilters={hasActiveFilters}
- tableState={tableState}
- setTableState={setTableState}
- allowSidebarViews={allowSidebarViews}
- showExport={showExport}
- exportPrefix={exportPrefix}
- exportDisabled={exportDisabled}
- toolbarExtra={toolbarContent}
- extraViewData={extraViewData}
- isExtraViewDataDirty={isExtraViewDataDirty}
- toolbarContent={toolbarContent}
- />
-
- )}
-
- {bulkActions && selectedIds.length > 0 && (
-
-
-
- )}
-
-
-
- {showPagination && (
-
- setTableState({ pageSize, pageIndex: 0 })}
- />
-
- )}
-
+ 0 ? filterPresence : null}>
+
+ {!hideHeader && (
+
+
+ table={table}
+ showColumnVisibility={showColumnVisibility}
+ showFilter={showFilter}
+ columnFilters={columnFilters}
+ defaultColumnVisibility={defaultColumnVisibility ?? EMPTY_COLUMN_VISIBILITY}
+ views={views || []}
+ viewType={viewType}
+ tableState={tableState}
+ setTableState={setTableState}
+ allowSidebarViews={allowSidebarViews}
+ showExport={showExport}
+ exportPrefix={exportPrefix}
+ exportDisabled={exportDisabled}
+ toolbarExtra={toolbarContent}
+ extraViewData={extraViewData}
+ isExtraViewDataDirty={isExtraViewDataDirty}
+ toolbarContent={toolbarContent}
+ onRowClick={onRowClick}
+ />
+
+ )}
+
+ {bulkActions && selectedIds.length > 0 && (
+
+
+
+ )}
+
+
+
+ {showPagination && (
+
+ setTableState({ pageSize, pageIndex: 0 })}
+ />
+
+ )}
+
+
);
}
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
index 5ff324ae..5b9c16d4 100644
--- a/src/components/ui/select.tsx
+++ b/src/components/ui/select.tsx
@@ -91,8 +91,11 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
+ React.ComponentPropsWithoutRef & {
+ /** Shown in the leading slot instead of the selected checkmark. */
+ leadingIcon?: React.ReactNode;
+ }
+>(({ className, children, leadingIcon, ...props }, ref) => (
-
-
-
+ {leadingIcon ?? (
+
+
+
+ )}
{children}
diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx
index e8db66d8..e17414c4 100644
--- a/src/components/ui/table.tsx
+++ b/src/components/ui/table.tsx
@@ -52,7 +52,7 @@ const TableHead = React.forwardRef>(
({ className, ...props }, ref) => (
- |
+ |
),
);
TableCell.displayName = 'TableCell';
diff --git a/src/lib/dashboard/table-widget/lender-profile-columns.tsx b/src/lib/dashboard/table-widget/lender-profile-columns.tsx
index 52864949..7bff8c03 100644
--- a/src/lib/dashboard/table-widget/lender-profile-columns.tsx
+++ b/src/lib/dashboard/table-widget/lender-profile-columns.tsx
@@ -285,15 +285,15 @@ function createProfileLenderNumberColumn(
t: (key: string) => string,
): ColumnDef {
const id = columnId(idPrefix, 'lenderNumber');
- return createColumn(
+ const column = createColumn(
{
accessorKey: id,
id,
header: 'table.lenderNumber',
- accessorFn: (row: T) => getLender(row)?.lenderNumber ?? '',
+ accessorFn: (row: T) => getLender(row)?.lenderNumber ?? null,
cell: ({ row }) => {
const value = getLender(row.original)?.lenderNumber;
- return value == null ? '' : String(value);
+ return value == null ? '' : `#${value}`;
},
meta: {
export: { type: 'integer' },
@@ -301,6 +301,8 @@ function createProfileLenderNumberColumn(
},
t,
);
+ column.filterFn = 'inNumberRange';
+ return column;
}
function createProfileEnumColumn(
diff --git a/src/lib/dashboard/table-widget/lender-table-column-registry.ts b/src/lib/dashboard/table-widget/lender-table-column-registry.ts
index 21139db7..6f552086 100644
--- a/src/lib/dashboard/table-widget/lender-table-column-registry.ts
+++ b/src/lib/dashboard/table-widget/lender-table-column-registry.ts
@@ -52,17 +52,24 @@ export function buildAllLenderTableColumns string,
locale: string,
): ColumnDef[] {
- return [
- createColumn(
- {
- accessorKey: 'lenderNumber',
- header: 'table.lenderNumber',
- meta: {
- export: { type: 'integer' },
- },
+ const lenderNumberColumn = createColumn(
+ {
+ accessorKey: 'lenderNumber',
+ header: 'table.lenderNumber',
+ cell: ({ row }) => {
+ const value = row.getValue('lenderNumber') as number | null | undefined;
+ return value == null ? '' : `#${value}`;
},
- t,
- ),
+ meta: {
+ export: { type: 'integer' },
+ },
+ },
+ t,
+ );
+ lenderNumberColumn.filterFn = 'inNumberRange';
+
+ return [
+ lenderNumberColumn,
createLenderNameColumn(t),
diff --git a/src/lib/dashboard/table-widget/loan-table-column-registry.ts b/src/lib/dashboard/table-widget/loan-table-column-registry.ts
index 0990daa6..0e0ed082 100644
--- a/src/lib/dashboard/table-widget/loan-table-column-registry.ts
+++ b/src/lib/dashboard/table-widget/loan-table-column-registry.ts
@@ -36,15 +36,15 @@ const LOAN_TABLE_STATIC_COLUMN_META: { id: string; labelKey: string }[] = [
{ id: 'loanNumber', labelKey: 'table.loanNumber' },
{ id: 'signDate', labelKey: 'table.signDate' },
{ id: 'amount', labelKey: 'table.amount' },
- { id: 'balance', labelKey: 'table.balance' },
{ id: 'deposits', labelKey: 'table.deposits' },
+ { id: 'withdrawals', labelKey: 'table.withdrawals' },
+ { id: 'balance', labelKey: 'table.balance' },
{ id: 'outstandingDepositSum', labelKey: 'table.outstandingDepositSum' },
{ id: 'outstandingDepositSinceDate', labelKey: 'table.outstandingDepositSinceDate' },
{ id: 'outstandingDepositSinceDays', labelKey: 'table.outstandingDepositSinceDays' },
{ id: 'depositsCount', labelKey: 'table.depositsCount' },
{ id: 'requiredDepositsCount', labelKey: 'table.requiredDepositsCount' },
{ id: 'outstandingDepositsCount', labelKey: 'table.outstandingDepositsCount' },
- { id: 'withdrawals', labelKey: 'table.withdrawals' },
{ id: 'notReclaimed', labelKey: 'table.notReclaimed' },
{ id: 'interestRate', labelKey: 'table.interestRate' },
{ id: 'interest', labelKey: 'table.interest' },
@@ -54,16 +54,38 @@ const LOAN_TABLE_STATIC_COLUMN_META: { id: string; labelKey: string }[] = [
{ id: 'repayDate', labelKey: 'table.repayDate' },
{ id: 'loanTermDays', labelKey: 'table.loanTerm' },
{ id: 'repaymentPeriodDays', labelKey: 'table.repaymentPeriod' },
+ { id: 'isSavingsContract', labelKey: 'table.isSavingsContract' },
{ id: 'status', labelKey: 'table.status' },
{ id: 'altInterestMethod', labelKey: 'table.altInterestMethod' },
{ id: 'contractStatus', labelKey: 'table.contractStatus' },
- { id: 'isSavingsContract', labelKey: 'table.isSavingsContract' },
];
const LOAN_COLUMN_GROUP = { key: 'loan' as const, order: 0 };
const LENDER_COLUMN_GROUP = { key: 'lender' as const, order: 1 };
-const DEFAULT_LOAN_TABLE_LEADING_COLUMN_IDS = ['lender.lenderNumber', 'lender.name', 'loanNumber'] as const;
+const DEFAULT_LOAN_TABLE_LEADING_COLUMN_IDS = [
+ 'loanNumber',
+ 'signDate',
+ 'lender.name',
+ 'lender.lenderNumber',
+ 'amount',
+ 'deposits',
+ 'withdrawals',
+ 'balance',
+ 'isSavingsContract',
+ 'status',
+] as const;
+
+const DEFAULT_LOAN_TABLE_VISIBLE_LOAN_COLUMN_IDS = [
+ 'loanNumber',
+ 'signDate',
+ 'amount',
+ 'deposits',
+ 'withdrawals',
+ 'balance',
+ 'isSavingsContract',
+ 'status',
+] as const;
function getColumnId(column: ColumnDef): string {
if (column.id) {
@@ -88,17 +110,15 @@ function orderLoanTableColumnsForDefaultDisplay(
}
export function buildLoanTableDefaultColumnVisibility(project: ProjectWithConfiguration): Record {
+ const visibleLoanColumnIds = new Set(DEFAULT_LOAN_TABLE_VISIBLE_LOAN_COLUMN_IDS);
const loanVisibility = Object.fromEntries(
- LOAN_TABLE_STATIC_COLUMN_META.map(({ id }) => [
- id,
- ['loanNumber', 'signDate', 'amount', 'balance', 'interestRate', 'status'].includes(id),
- ]),
+ LOAN_TABLE_STATIC_COLUMN_META.map(({ id }) => [id, visibleLoanColumnIds.has(id)]),
);
return {
...loanVisibility,
...createAdditionalFieldDefaultColumnVisibility('additionalFields', project.configuration.loanAdditionalFields),
- ...buildLenderProfileDefaultColumnVisibility(project, 'lender.', ['lenderNumber', 'name']),
+ ...buildLenderProfileDefaultColumnVisibility(project, 'lender.', ['name', 'lenderNumber']),
};
}
@@ -110,11 +130,12 @@ export function buildLoanTableColumns(
durationT: (key: string, values?: Record) => string,
): ColumnDef[] {
return [
- createNumberColumn('loanNumber', 'table.loanNumber', t, locale),
+ createNumberColumn('loanNumber', 'table.loanNumber', t, locale, { hashPrefix: true }),
createDateColumn('signDate', 'table.signDate', t, locale),
createCurrencyColumn('amount', 'table.amount', t, locale),
- createCurrencyColumn('balance', 'table.balance', t, locale),
createCurrencyColumn('deposits', 'table.deposits', t, locale),
+ createCurrencyColumn('withdrawals', 'table.withdrawals', t, locale),
+ createCurrencyColumn('balance', 'table.balance', t, locale),
createNullableCurrencyColumn('outstandingDepositSum', 'table.outstandingDepositSum', t),
createDateColumn('outstandingDepositSinceDate', 'table.outstandingDepositSinceDate', t, locale),
createDurationDaysColumn(
@@ -138,7 +159,6 @@ export function buildLoanTableColumns(
locale,
{ integer: true },
),
- createCurrencyColumn('withdrawals', 'table.withdrawals', t, locale),
createCurrencyColumn('notReclaimed', 'table.notReclaimed', t, locale),
createPercentageColumn('interestRate', 'table.interestRate', t, locale),
createCurrencyColumn('interest', 'table.interest', t, locale),
@@ -155,18 +175,29 @@ export function buildLoanTableColumns(
createDateColumn('repayDate', 'table.repayDate', t, locale),
createDurationDaysColumn('loanTermDays', 'table.loanTerm', t, durationT),
createDurationDaysColumn('repaymentPeriodDays', 'table.repaymentPeriod', t, durationT),
- createEnumBadgeColumn('status', 'table.status', 'enums.loan.status', t, commonT, (value) => {
- switch (value) {
- case 'ACTIVE':
- return 'default';
- case 'TERMINATED':
- return 'destructive';
- case 'PENDING':
- return 'secondary';
- default:
- return 'outline';
- }
+ createBooleanColumn('isSavingsContract', 'table.isSavingsContract', t, commonT, {
+ align: 'center',
}),
+ createEnumBadgeColumn(
+ 'status',
+ 'table.status',
+ 'enums.loan.status',
+ t,
+ commonT,
+ (value) => {
+ switch (value) {
+ case 'ACTIVE':
+ return 'default';
+ case 'TERMINATED':
+ return 'destructive';
+ case 'PENDING':
+ return 'secondary';
+ default:
+ return 'outline';
+ }
+ },
+ { align: 'center' },
+ ),
createEnumBadgeColumn(
'altInterestMethod',
'table.altInterestMethod',
@@ -190,7 +221,6 @@ export function buildLoanTableColumns(
}
},
),
- createBooleanColumn('isSavingsContract', 'table.isSavingsContract', t, commonT),
...createAdditionalFieldsColumns(
project.configuration.loanAdditionalFields,
'additionalFields',
@@ -323,15 +353,15 @@ export const LOAN_TABLE_COLUMN_IDS = [
'loanNumber',
'signDate',
'amount',
- 'balance',
'deposits',
+ 'withdrawals',
+ 'balance',
'outstandingDepositSum',
'outstandingDepositSinceDate',
'outstandingDepositSinceDays',
'depositsCount',
'requiredDepositsCount',
'outstandingDepositsCount',
- 'withdrawals',
'notReclaimed',
'interestRate',
'interest',
@@ -341,8 +371,8 @@ export const LOAN_TABLE_COLUMN_IDS = [
'repayDate',
'loanTermDays',
'repaymentPeriodDays',
+ 'isSavingsContract',
'status',
'altInterestMethod',
'contractStatus',
- 'isSavingsContract',
] as const;
diff --git a/src/lib/dashboard/table-widget/transaction-table-column-registry.tsx b/src/lib/dashboard/table-widget/transaction-table-column-registry.tsx
index 636af7e3..539a8674 100644
--- a/src/lib/dashboard/table-widget/transaction-table-column-registry.tsx
+++ b/src/lib/dashboard/table-widget/transaction-table-column-registry.tsx
@@ -40,9 +40,14 @@ const LOAN_TABLE_STATIC_COLUMN_META: { id: string; labelKey: string }[] = [
{ id: 'loanNumber', labelKey: 'table.loanNumber' },
{ id: 'signDate', labelKey: 'table.signDate' },
{ id: 'amount', labelKey: 'table.amount' },
- { id: 'balance', labelKey: 'table.balance' },
{ id: 'deposits', labelKey: 'table.deposits' },
{ id: 'withdrawals', labelKey: 'table.withdrawals' },
+ { id: 'balance', labelKey: 'table.balance' },
+ { id: 'outstandingDepositSum', labelKey: 'table.outstandingDepositSum' },
+ { id: 'outstandingDepositSinceDate', labelKey: 'table.outstandingDepositSinceDate' },
+ { id: 'depositsCount', labelKey: 'table.depositsCount' },
+ { id: 'requiredDepositsCount', labelKey: 'table.requiredDepositsCount' },
+ { id: 'outstandingDepositsCount', labelKey: 'table.outstandingDepositsCount' },
{ id: 'notReclaimed', labelKey: 'table.notReclaimed' },
{ id: 'interestRate', labelKey: 'table.interestRate' },
{ id: 'interest', labelKey: 'table.interest' },
@@ -52,10 +57,10 @@ const LOAN_TABLE_STATIC_COLUMN_META: { id: string; labelKey: string }[] = [
{ id: 'repayDate', labelKey: 'table.repayDate' },
{ id: 'loanTermDays', labelKey: 'table.loanTerm' },
{ id: 'repaymentPeriodDays', labelKey: 'table.repaymentPeriod' },
+ { id: 'isSavingsContract', labelKey: 'table.isSavingsContract' },
{ id: 'status', labelKey: 'table.status' },
{ id: 'altInterestMethod', labelKey: 'table.altInterestMethod' },
{ id: 'contractStatus', labelKey: 'table.contractStatus' },
- { id: 'isSavingsContract', labelKey: 'table.isSavingsContract' },
];
const DEFAULT_VISIBLE_COLUMN_IDS = [
diff --git a/src/lib/entity-filters/filter-definitions.ts b/src/lib/entity-filters/filter-definitions.ts
index d297b4e6..f70f4e27 100644
--- a/src/lib/entity-filters/filter-definitions.ts
+++ b/src/lib/entity-filters/filter-definitions.ts
@@ -12,6 +12,7 @@ import {
import type { DataTableColumnFilters } from '@/components/ui/data-table';
import { createAdditionalFieldFilters } from '@/lib/table-column-utils';
import type { EntityFilterEntity, EntityFilterFieldOption } from '@/types/entity-filters';
+import type { NumberFilterOperator } from '@/types/number-filter-value';
import type { ProjectWithConfiguration } from '@/types/projects';
export type DataTableColumnFilterType = 'text' | 'select' | 'multi-select' | 'number' | 'date' | 'boolean';
@@ -21,6 +22,7 @@ export type DataTableColumnFilterDefinition = {
label: string;
options?: { label: string; value: string }[];
allowEmpty?: boolean;
+ defaultOperator?: NumberFilterOperator;
};
const LOAN_STATUS_OPTIONS = [
@@ -53,9 +55,9 @@ export function buildLoanColumnFiltersMap(
commonT: (key: string) => string,
): Record {
return {
- loanNumber: { type: 'number', label: t('table.loanNumber') },
+ loanNumber: { type: 'number', label: t('table.loanNumber'), defaultOperator: 'eq' },
signDate: { type: 'date', label: t('table.signDate'), allowEmpty: false },
- amount: { type: 'number', label: t('table.amount') },
+ amount: { type: 'number', label: t('table.amount'), defaultOperator: 'eq' },
balance: { type: 'number', label: t('table.balance') },
deposits: { type: 'number', label: t('table.deposits') },
outstandingDepositSum: { type: 'number', label: t('table.outstandingDepositSum') },
@@ -70,7 +72,7 @@ export function buildLoanColumnFiltersMap(
outstandingDepositsCount: { type: 'number', label: t('table.outstandingDepositsCount') },
withdrawals: { type: 'number', label: t('table.withdrawals') },
notReclaimed: { type: 'number', label: t('table.notReclaimed') },
- interestRate: { type: 'number', label: t('table.interestRate') },
+ interestRate: { type: 'number', label: t('table.interestRate'), defaultOperator: 'eq' },
interest: { type: 'number', label: t('table.interest') },
interestPaid: { type: 'number', label: t('table.interestPaid') },
terminationType: {
@@ -136,7 +138,7 @@ export function buildLenderProfileColumnFiltersMap(
const additionalFieldsPrefix = idPrefix ? `${idPrefix}additionalFields` : 'additionalFields';
const filters: Record = {
- lenderNumber: { type: 'number', label: t('table.lenderNumber') },
+ lenderNumber: { type: 'number', label: t('table.lenderNumber'), defaultOperator: 'eq' },
type: {
type: 'select',
label: t('table.type'),
@@ -188,7 +190,7 @@ export function buildLenderProfileColumnFiltersMap(
if (options?.includeAggregates && options.tLoans) {
const tLoans = options.tLoans;
Object.assign(filters, {
- amount: { type: 'number', label: tLoans('table.amount') },
+ amount: { type: 'number', label: tLoans('table.amount'), defaultOperator: 'eq' },
balance: { type: 'number', label: tLoans('table.balance') },
deposits: { type: 'number', label: tLoans('table.deposits') },
withdrawals: { type: 'number', label: tLoans('table.withdrawals') },
diff --git a/src/lib/entity-filters/filter-matchers.ts b/src/lib/entity-filters/filter-matchers.ts
index e3a07604..03e253b9 100644
--- a/src/lib/entity-filters/filter-matchers.ts
+++ b/src/lib/entity-filters/filter-matchers.ts
@@ -1,11 +1,17 @@
import type { DataTableColumnFilterType } from '@/lib/entity-filters/filter-definitions';
import { resolveDateFilterBounds } from '@/lib/entity-filters/resolve-date-filter-range';
+import { formatCurrency, formatNumber, NumberParser } from '@/lib/utils';
import { parseBooleanFilterValue } from '@/types/boolean-filter-value';
import { parseDateFilterValue } from '@/types/date-filter-value';
import { parseEnumFilterValue } from '@/types/enum-filter-value';
import { parseNumberFilterValue } from '@/types/number-filter-value';
import { parseTextFilterValue } from '@/types/text-filter-value';
+const deNumberParser = new NumberParser('de-DE');
+
+/** Half-cent tolerance for currency-style equality in global search. */
+const GLOBAL_FILTER_NUMBER_EPSILON = 0.005;
+
function isNullishOrBlank(value: unknown): boolean {
if (value === null || value === undefined) {
return true;
@@ -16,6 +22,83 @@ function isNullishOrBlank(value: unknown): boolean {
return false;
}
+function toFiniteNumber(value: unknown): number | null {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === 'string' && value.trim() !== '') {
+ const asNumber = Number(value);
+ if (Number.isFinite(asNumber)) {
+ return asNumber;
+ }
+ }
+ if (
+ value &&
+ typeof value === 'object' &&
+ 'toNumber' in value &&
+ typeof (value as { toNumber: unknown }).toNumber === 'function'
+ ) {
+ const asNumber = (value as { toNumber: () => number }).toNumber();
+ if (Number.isFinite(asNumber)) {
+ return asNumber;
+ }
+ }
+ return null;
+}
+
+function parseLocalizedSearchNumber(filter: string): number | null {
+ const stripped = deNumberParser.strip(filter);
+ if (!stripped) {
+ return null;
+ }
+ const parsed = deNumberParser.parse(stripped);
+ if (parsed == null || !Number.isFinite(parsed)) {
+ return null;
+ }
+ return parsed;
+}
+
+function normalizeSearchText(value: string): string {
+ return value.toLowerCase().replace(/\u00a0/g, ' ');
+}
+
+/**
+ * Global “search all” matcher: substring match, plus German/currency number parsing
+ * so queries like `3000,00`, `3.000,00`, or `3000€` hit numeric cell values.
+ */
+export function matchesGlobalFilter(value: unknown, filterValue: unknown): boolean {
+ const rawFilter = String(filterValue).trim();
+ if (!rawFilter) {
+ return true;
+ }
+ if (value == null || value === '') {
+ return false;
+ }
+
+ const searchFilter = normalizeSearchText(rawFilter);
+ if (normalizeSearchText(String(value)).includes(searchFilter)) {
+ return true;
+ }
+
+ // Number-aware matching only when the query contains a digit (avoids bare `€` matching all amounts).
+ if (!/\d/.test(rawFilter)) {
+ return false;
+ }
+
+ const numericValue = toFiniteNumber(value);
+ if (numericValue == null) {
+ return false;
+ }
+
+ const parsedFilter = parseLocalizedSearchNumber(rawFilter);
+ if (parsedFilter != null && Math.abs(numericValue - parsedFilter) < GLOBAL_FILTER_NUMBER_EPSILON) {
+ return true;
+ }
+
+ const displays = [formatCurrency(numericValue), formatNumber(numericValue), formatNumber(numericValue, 0, 0)];
+ return displays.some((display) => normalizeSearchText(display).includes(searchFilter));
+}
+
export function matchesTextFilter(value: unknown, filterValue: unknown): boolean {
const parsed = parseTextFilterValue(filterValue);
diff --git a/src/lib/entity-filters/resolve-date-filter-range.ts b/src/lib/entity-filters/resolve-date-filter-range.ts
index 9bcb74eb..e62c4595 100644
--- a/src/lib/entity-filters/resolve-date-filter-range.ts
+++ b/src/lib/entity-filters/resolve-date-filter-range.ts
@@ -32,6 +32,9 @@ export function resolveDateFilterBounds(
};
}
case 'last': {
+ if (parsed.amount == null) {
+ return null;
+ }
const pointA = moment(referenceDate).subtract(parsed.amount, parsed.unit);
const pointB = moment(referenceDate);
const start = moment.min(pointA, pointB).startOf('day');
@@ -39,6 +42,9 @@ export function resolveDateFilterBounds(
return { start: start.toDate(), end: end.toDate() };
}
case 'next': {
+ if (parsed.amount == null) {
+ return null;
+ }
const pointA = moment(referenceDate);
const pointB = moment(referenceDate).add(parsed.amount, parsed.unit);
const start = moment.min(pointA, pointB).startOf('day');
@@ -46,10 +52,16 @@ export function resolveDateFilterBounds(
return { start: start.toDate(), end: end.toDate() };
}
case 'olderThan': {
+ if (parsed.amount == null) {
+ return null;
+ }
const end = moment(referenceDate).subtract(parsed.amount, parsed.unit).endOf('day');
return { start: null, end: end.toDate() };
}
case 'newerThan': {
+ if (parsed.amount == null) {
+ return null;
+ }
const start = moment(referenceDate).subtract(parsed.amount, parsed.unit).startOf('day');
return { start: start.toDate(), end: null };
}
diff --git a/src/lib/hooks/use-table-url-state.ts b/src/lib/hooks/use-table-url-state.ts
index 2bf6cf58..e465dc81 100644
--- a/src/lib/hooks/use-table-url-state.ts
+++ b/src/lib/hooks/use-table-url-state.ts
@@ -10,6 +10,8 @@ import { tableUrlNuqsOptions, tableUrlParsers } from '@/lib/table-url-parsers';
export type TableUrlState = {
globalFilter: string;
+ /** Selected quick-search column id; empty string means “Alle”. Not saved in views. */
+ quickSearchField: string;
sorting: SortingState;
columnFilters: ColumnFiltersState;
columnVisibility: VisibilityState;
@@ -17,6 +19,8 @@ export type TableUrlState = {
pageSize: number;
selectedView: string;
viewName: string;
+ /** Whether additional filter chips are shown. Saved in views. */
+ filtersExpanded: boolean;
};
export type SetTableUrlState = (update: Partial) => void;
@@ -24,6 +28,7 @@ export type SetTableUrlState = (update: Partial) => void;
/** The baseline state when no view is selected */
const DEFAULT_BASELINE: TableUrlState = {
globalFilter: '',
+ quickSearchField: '',
sorting: [],
columnFilters: [],
columnVisibility: {},
@@ -31,6 +36,7 @@ const DEFAULT_BASELINE: TableUrlState = {
pageSize: 25,
selectedView: '',
viewName: '',
+ filtersExpanded: false,
};
/**
@@ -43,12 +49,14 @@ function viewToBaseline(
): Omit {
return {
globalFilter: data?.globalFilter ?? '',
+ quickSearchField: '',
sorting: data?.sorting ?? [],
columnFilters: data?.columnFilters ?? [],
columnVisibility: data?.columnVisibility ?? defaultColumnVisibility,
pageIndex: 0, // always reset page on view load
pageSize: data?.pagination?.pageSize ?? data?.pageSize ?? 25,
viewName: '',
+ filtersExpanded: data?.filtersExpanded ?? false,
};
}
@@ -95,6 +103,7 @@ export function useTableUrlState(options: UseTableUrlStateOptions = {}) {
const state = useMemo(() => {
return {
globalFilter: rawState.q ?? baseline.globalFilter,
+ quickSearchField: rawState.sf ?? baseline.quickSearchField,
sorting: rawState.sort ?? baseline.sorting,
columnFilters: rawState.filters ?? baseline.columnFilters,
columnVisibility: rawState.cols ? { ...defaultColumnVisibility, ...rawState.cols } : baseline.columnVisibility,
@@ -102,6 +111,7 @@ export function useTableUrlState(options: UseTableUrlStateOptions = {}) {
pageSize: rawState.pageSize ?? baseline.pageSize,
selectedView: rawState.view ?? baseline.selectedView,
viewName: rawState.viewName ?? '',
+ filtersExpanded: rawState.fe ?? baseline.filtersExpanded,
};
}, [rawState, baseline, defaultColumnVisibility]);
@@ -141,6 +151,10 @@ export function useTableUrlState(options: UseTableUrlStateOptions = {}) {
if (update.globalFilter !== undefined) {
raw.q = update.globalFilter !== effectiveBaseline.globalFilter ? update.globalFilter : null;
}
+ if (update.quickSearchField !== undefined) {
+ raw.sf =
+ update.quickSearchField !== effectiveBaseline.quickSearchField ? update.quickSearchField || null : null;
+ }
if (update.sorting !== undefined) {
raw.sort = !isEqual(update.sorting, effectiveBaseline.sorting) ? update.sorting : null;
}
@@ -161,6 +175,9 @@ export function useTableUrlState(options: UseTableUrlStateOptions = {}) {
if (update.viewName !== undefined) {
raw.viewName = update.viewName || null;
}
+ if (update.filtersExpanded !== undefined) {
+ raw.fe = update.filtersExpanded !== effectiveBaseline.filtersExpanded ? update.filtersExpanded : null;
+ }
setRawState(raw, {
shallow: true,
diff --git a/src/lib/hooks/use-transaction-table-url-state.ts b/src/lib/hooks/use-transaction-table-url-state.ts
index cef10561..a6e330be 100644
--- a/src/lib/hooks/use-transaction-table-url-state.ts
+++ b/src/lib/hooks/use-transaction-table-url-state.ts
@@ -24,12 +24,14 @@ export type TransactionTableUrlState = TableUrlState & TransactionTableExtraView
const DEFAULT_BASELINE: Omit = {
globalFilter: '',
+ quickSearchField: '',
sorting: [{ id: 'transaction.date', desc: true }],
columnFilters: [],
columnVisibility: {},
pageIndex: 0,
pageSize: 25,
viewName: '',
+ filtersExpanded: false,
txRange: DEFAULT_TRANSACTION_TIME_RANGE,
txRangeFrom: getDefaultTransactionCustomFrom(),
txRangeTo: getDefaultTransactionCustomTo(),
@@ -44,12 +46,14 @@ function viewToBaseline(
const extra = parseTransactionExtraViewData(data);
return {
globalFilter: data?.globalFilter ?? '',
+ quickSearchField: '',
sorting: data?.sorting ?? DEFAULT_BASELINE.sorting,
columnFilters: data?.columnFilters ?? [],
columnVisibility: data?.columnVisibility ?? defaultColumnVisibility,
pageIndex: 0,
pageSize: data?.pagination?.pageSize ?? data?.pageSize ?? 25,
viewName: '',
+ filtersExpanded: data?.filtersExpanded ?? false,
...extra,
};
}
@@ -91,6 +95,7 @@ export function useTransactionTableUrlState(options: UseTransactionTableUrlState
const state = useMemo(() => {
return {
globalFilter: rawState.q ?? baseline.globalFilter,
+ quickSearchField: rawState.sf ?? baseline.quickSearchField,
sorting: rawState.sort ?? baseline.sorting,
columnFilters: rawState.filters ?? baseline.columnFilters,
columnVisibility: rawState.cols ? { ...defaultColumnVisibility, ...rawState.cols } : baseline.columnVisibility,
@@ -98,6 +103,7 @@ export function useTransactionTableUrlState(options: UseTransactionTableUrlState
pageSize: rawState.pageSize ?? baseline.pageSize,
selectedView: rawState.view ?? baseline.selectedView,
viewName: rawState.viewName ?? '',
+ filtersExpanded: rawState.fe ?? baseline.filtersExpanded,
txRange: rawState.txRange ?? baseline.txRange,
txRangeFrom: rawState.txRangeFrom ?? baseline.txRangeFrom,
txRangeTo: rawState.txRangeTo ?? baseline.txRangeTo,
@@ -136,6 +142,10 @@ export function useTransactionTableUrlState(options: UseTransactionTableUrlState
if (update.globalFilter !== undefined) {
raw.q = update.globalFilter !== effectiveBaseline.globalFilter ? update.globalFilter : null;
}
+ if (update.quickSearchField !== undefined) {
+ raw.sf =
+ update.quickSearchField !== effectiveBaseline.quickSearchField ? update.quickSearchField || null : null;
+ }
if (update.sorting !== undefined) {
raw.sort = !isEqual(update.sorting, effectiveBaseline.sorting) ? update.sorting : null;
}
@@ -156,6 +166,9 @@ export function useTransactionTableUrlState(options: UseTransactionTableUrlState
if (update.viewName !== undefined) {
raw.viewName = update.viewName || null;
}
+ if (update.filtersExpanded !== undefined) {
+ raw.fe = update.filtersExpanded !== effectiveBaseline.filtersExpanded ? update.filtersExpanded : null;
+ }
if (update.txRange !== undefined) {
raw.txRange = update.txRange !== effectiveBaseline.txRange ? update.txRange : null;
}
diff --git a/src/lib/table-column-utils.tsx b/src/lib/table-column-utils.tsx
index 268c10c1..a3d42b5f 100644
--- a/src/lib/table-column-utils.tsx
+++ b/src/lib/table-column-utils.tsx
@@ -6,6 +6,7 @@ import type { ColumnGroupMeta, DataTableColumnFilters } from '@/components/ui/da
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
import {
matchesBooleanFilter,
+ matchesDateFilter,
matchesEnumFilter,
matchesTextFilter,
} from '@/lib/entity-filters/filter-matchers';
@@ -30,6 +31,11 @@ export function enumFilter(row: Row, columnId: string, filterValue: unknow
return matchesEnumFilter(row.getValue(columnId), filterValue, 'eq');
}
+// Define the custom filter function for date fields
+export function dateRangeFilter(row: Row, columnId: string, filterValue: unknown) {
+ return matchesDateFilter(row.getValue(columnId), filterValue, new Date());
+}
+
// Define the custom filter function type
export type FilterFn = 'equals' | 'contains' | 'startsWith' | 'endsWith' | 'compoundText' | 'numberRange' | 'enum';
@@ -39,13 +45,38 @@ export function accessorKeyToColumnId(accessorKey: string): string {
return accessorKey.replaceAll('.', '_');
}
+type TranslateFn = ((key: string) => string) & { has?: (key: string) => boolean };
+
type ColumnConfig = ColumnDef & {
accessorKey: string;
+ /** Translation key for the long column name (filters, export, column menu). */
header?: string | undefined;
+ /** Optional translation key for the short header label. Defaults to `${header}Short` when present. */
+ headerShort?: string | undefined;
+ /** Optional translation key for the column description. Defaults to `${header}Description` when present. */
+ headerDescription?: string | undefined;
id?: string | undefined;
align?: 'left' | 'right' | 'center';
};
+function resolveOptionalTranslation(t: TranslateFn, key: string | undefined): string | undefined {
+ if (!key) return undefined;
+ if (t.has && !t.has(key)) return undefined;
+ return t(key);
+}
+
+function resolveColumnLabels(
+ t: TranslateFn,
+ headerKey: string | undefined,
+ options?: { headerShort?: string; headerDescription?: string },
+): { shortName?: string; longName?: string; description?: string } {
+ if (!headerKey) return {};
+ const longName = t(headerKey);
+ const shortName = resolveOptionalTranslation(t, options?.headerShort ?? `${headerKey}Short`) ?? longName;
+ const description = resolveOptionalTranslation(t, options?.headerDescription ?? `${headerKey}Description`);
+ return { shortName, longName, description };
+}
+
function mergeExportMeta(
column: ColumnDef,
exportMeta: {
@@ -68,11 +99,19 @@ function mergeExportMeta(
}
// Create a basic column definition
-export function createColumn(config: ColumnConfig, t: (key: string) => string): ColumnDef {
+export function createColumn(config: ColumnConfig, t: TranslateFn): ColumnDef {
+ const { headerShort, headerDescription, header, ...restConfig } = config;
+ const { shortName, longName, description } = resolveColumnLabels(t, header, {
+ headerShort,
+ headerDescription,
+ });
+
const column = {
- ...config,
+ ...restConfig,
header: ({ column }) =>
- config.header ? : undefined,
+ shortName ? (
+
+ ) : undefined,
filterFn: config.filterFn || compoundTextFilter,
sortingFn:
config.sortingFn ||
@@ -86,13 +125,16 @@ export function createColumn(config: ColumnConfig, t: (key: string) => str
}),
meta: {
...config.meta,
+ labelShort: shortName,
+ labelLong: longName,
+ description,
style: {
textAlign: config.align ?? 'left',
...config.meta?.style,
},
export: {
type: 'text' as const,
- label: config.header ? t(config.header) : config.meta?.export?.label,
+ label: longName ?? config.meta?.export?.label,
...config.meta?.export,
},
},
@@ -230,7 +272,7 @@ export function createNumberColumn(
headerKey: string | undefined,
t: (key: string) => string,
locale: string,
- options?: { integer?: boolean },
+ options?: { integer?: boolean; hashPrefix?: boolean },
): ColumnDef {
const parser = new NumberParser(locale);
const column = createColumn(
@@ -239,7 +281,11 @@ export function createNumberColumn(
header: headerKey,
cell: ({ row }) => {
const value = formatTableNumberValue(row.getValue(accessorKey), parser);
- return {value}
;
+ if (value === '' || value == null) {
+ return ;
+ }
+ const display = options?.hashPrefix ? `#${value}` : value;
+ return {display}
;
},
},
t,
@@ -320,32 +366,31 @@ export function createDateColumn(
t: (key: string) => string,
locale?: string,
): ColumnDef {
- return mergeExportMeta(
- createColumn(
- {
- accessorKey,
- header: headerKey,
- cell: ({ row }) => {
- const dateStr = row.getValue(accessorKey) as string;
- if (!dateStr) return '';
- try {
- const date = new Date(dateStr);
- return Number.isNaN(date.getTime())
- ? ''
- : date.toLocaleDateString(resolveIntlLocaleForDates(locale ?? 'de'), {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
- });
- } catch (_) {
- return '';
- }
- },
+ const column = createColumn(
+ {
+ accessorKey,
+ header: headerKey,
+ cell: ({ row }) => {
+ const dateStr = row.getValue(accessorKey) as string;
+ if (!dateStr) return '';
+ try {
+ const date = new Date(dateStr);
+ return Number.isNaN(date.getTime())
+ ? ''
+ : date.toLocaleDateString(resolveIntlLocaleForDates(locale ?? 'de'), {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ });
+ } catch (_) {
+ return '';
+ }
},
- t,
- ),
- { type: 'date' },
+ },
+ t,
);
+ column.filterFn = dateRangeFilter as ColumnDef['filterFn'];
+ return mergeExportMeta(column, { type: 'date' });
}
type PercentageColumnFormattingOptions = {
@@ -418,13 +463,16 @@ export function createEnumBadgeColumn(
t: (key: string) => string,
commonT: (key: string) => string,
getBadgeVariant?: (value: string) => 'default' | 'secondary' | 'destructive' | 'outline',
+ options?: { align?: 'left' | 'right' | 'center' },
): ColumnDef {
const columnId = accessorKeyToColumnId(accessorKey);
+ const align = options?.align ?? 'left';
const column = createColumn(
{
accessorKey,
id: columnId,
header: headerKey,
+ align,
cell: ({ row }) => {
const value = row.getValue(columnId) as string;
if (!value) return '';
@@ -438,7 +486,13 @@ export function createEnumBadgeColumn(
variant = getBadgeVariant(value);
}
- return {enumText};
+ return (
+
+
+ {enumText}
+
+
+ );
},
filterFn: enumFilter,
sortingFn: (rowA, rowB, columnId) => {
@@ -473,14 +527,19 @@ export function createBooleanColumn(
headerKey: string,
t: (key: string) => string,
commonT: (key: string) => string,
+ options?: { align?: 'left' | 'right' | 'center' },
): ColumnDef {
const formatBoolean = (value: unknown) => (value === true ? commonT('ui.boolean.yes') : commonT('ui.boolean.no'));
+ const align = options?.align ?? 'left';
const column = createColumn(
{
accessorKey,
header: headerKey,
- cell: ({ row }) => formatBoolean(row.getValue(accessorKey)),
+ align,
+ cell: ({ row }) => (
+ {formatBoolean(row.getValue(accessorKey))}
+ ),
filterFn: booleanFilter,
sortingFn: (rowA, rowB, columnId) => {
const a = rowA.getValue(columnId) === true ? 1 : 0;
@@ -733,7 +792,7 @@ export function createAdditionalFieldsColumns(
return mergeExportMeta(
{
...createDateColumn(fieldKey, undefined, t, locale),
- header: ({ column }) => ,
+ header: ({ column }) => ,
id: fieldKey,
} as ColumnDef,
{ label: field.name },
@@ -745,7 +804,7 @@ export function createAdditionalFieldsColumns(
return mergeExportMeta(
{
...createCurrencyColumn(fieldKey, undefined, t, locale),
- header: ({ column }) => ,
+ header: ({ column }) => ,
id: fieldKey,
} as ColumnDef,
{ label: field.name },
@@ -755,7 +814,7 @@ export function createAdditionalFieldsColumns(
return mergeExportMeta(
{
...createPercentageColumn(fieldKey, undefined, t, locale),
- header: ({ column }) => ,
+ header: ({ column }) => ,
id: fieldKey,
} as ColumnDef,
{ label: field.name },
@@ -764,7 +823,7 @@ export function createAdditionalFieldsColumns(
return mergeExportMeta(
{
...createNumberColumn(fieldKey, undefined, t, locale, { integer: false }),
- header: ({ column }) => ,
+ header: ({ column }) => ,
id: fieldKey,
} as ColumnDef,
{ label: field.name },
@@ -787,7 +846,7 @@ export function createAdditionalFieldsColumns(
t,
),
id: fieldKey,
- header: ({ column }) => ,
+ header: ({ column }) => ,
} as ColumnDef,
{ label: field.name },
);
@@ -803,7 +862,7 @@ export function createAdditionalFieldsColumns(
},
t,
),
- header: ({ column }) => ,
+ header: ({ column }) => ,
} as ColumnDef,
{ label: field.name },
);
diff --git a/src/lib/table-filter-presence.ts b/src/lib/table-filter-presence.ts
new file mode 100644
index 00000000..f01ef75d
--- /dev/null
+++ b/src/lib/table-filter-presence.ts
@@ -0,0 +1,276 @@
+import type { ColumnFiltersState, Table, VisibilityState } from '@tanstack/react-table';
+
+import type { DataTableColumnFilters } from '@/components/ui/data-table';
+import type { TableUrlState } from '@/lib/hooks/use-table-url-state';
+import { isInactiveBooleanFilterValue } from '@/types/boolean-filter-value';
+import { createDefaultDateFilterValue, isInactiveDateFilterValue } from '@/types/date-filter-value';
+import { createDefaultEnumFilterValue, isInactiveEnumFilterValue } from '@/types/enum-filter-value';
+import {
+ createDefaultNumberFilterValue,
+ isInactiveNumberFilterValue,
+ type NumberFilterOperator,
+} from '@/types/number-filter-value';
+import { createDefaultTextFilterValue, isInactiveTextFilterValue } from '@/types/text-filter-value';
+
+export const GLOBAL_SEARCH_ALL = '__all__';
+export const FILTER_REMOVE_VALUE = '__remove__';
+
+export type ColumnFilterConfig = DataTableColumnFilters[string];
+
+export function isInactiveFilterValue(
+ value: unknown,
+ type: ColumnFilterConfig['type'],
+ defaultOperator?: NumberFilterOperator,
+): boolean {
+ if (type === 'boolean') {
+ return isInactiveBooleanFilterValue(value);
+ }
+
+ if (value && typeof value === 'object' && !Array.isArray(value) && 'operator' in value) {
+ const operator = (value as { operator: string }).operator;
+ if (operator === 'empty' || operator === 'notEmpty') {
+ return false;
+ }
+ switch (type) {
+ case 'date':
+ return isInactiveDateFilterValue(value);
+ case 'number':
+ return isInactiveNumberFilterValue(value, defaultOperator);
+ case 'select':
+ return isInactiveEnumFilterValue(value, 'eq');
+ case 'multi-select':
+ return isInactiveEnumFilterValue(value, 'in');
+ default:
+ return isInactiveTextFilterValue(value);
+ }
+ }
+
+ switch (type) {
+ case 'number':
+ return isInactiveNumberFilterValue(value, defaultOperator);
+ case 'select':
+ return isInactiveEnumFilterValue(value, 'eq');
+ case 'multi-select':
+ return isInactiveEnumFilterValue(value, 'in');
+ case 'date':
+ return isInactiveDateFilterValue(value);
+ default:
+ return isInactiveTextFilterValue(value);
+ }
+}
+
+export function createDefaultFilterValue(config: ColumnFilterConfig): unknown {
+ switch (config.type) {
+ case 'boolean':
+ return '';
+ case 'number':
+ return createDefaultNumberFilterValue(config.defaultOperator ?? 'between');
+ case 'date':
+ return createDefaultDateFilterValue();
+ case 'select':
+ return createDefaultEnumFilterValue('eq');
+ case 'multi-select':
+ return createDefaultEnumFilterValue('in');
+ default:
+ return createDefaultTextFilterValue();
+ }
+}
+
+export function getVisibleFilterColumnIds(
+ table: Table,
+ columnFilters: DataTableColumnFilters,
+ columnVisibility: VisibilityState,
+): string[] {
+ return table
+ .getAllLeafColumns()
+ .filter((column) => {
+ const visibleInState = columnVisibility[column.id] !== false;
+ return visibleInState && column.getIsVisible() && columnFilters[column.id];
+ })
+ .map((column) => column.id);
+}
+
+/** Targets already owned by QS (when not Alle) or by a present column filter. */
+export function getTakenFilterTargets(tableState: Pick): Set {
+ const taken = new Set(tableState.columnFilters.map((filter) => filter.id));
+ if (tableState.quickSearchField) {
+ taken.add(tableState.quickSearchField);
+ }
+ return taken;
+}
+
+export function getAvailableFilterTargets(
+ table: Table,
+ columnFilters: DataTableColumnFilters,
+ tableState: Pick,
+ /** Current chip target stays selectable in its own dropdown. */
+ includeColumnId?: string,
+): string[] {
+ const taken = getTakenFilterTargets(tableState);
+ if (includeColumnId) {
+ taken.delete(includeColumnId);
+ }
+ return getVisibleFilterColumnIds(table, columnFilters, tableState.columnVisibility).filter((id) => !taken.has(id));
+}
+
+export function getNextAvailableFilterTarget(
+ table: Table,
+ columnFilters: DataTableColumnFilters,
+ tableState: Pick,
+): string | null {
+ return getAvailableFilterTargets(table, columnFilters, tableState)[0] ?? null;
+}
+
+export function resolveColumnFilterLabel(
+ table: Table,
+ columnId: string,
+ columnFilters: DataTableColumnFilters,
+): string {
+ const column = table.getColumn(columnId);
+ const config = columnFilters[columnId];
+ return (
+ column?.columnDef.meta?.labelShort ??
+ config?.label ??
+ column?.columnDef.meta?.labelLong ??
+ column?.columnDef.meta?.export?.label ??
+ columnId
+ );
+}
+
+export function isFilterPresent(
+ columnId: string,
+ tableState: Pick,
+): boolean {
+ if (tableState.quickSearchField === columnId) {
+ return true;
+ }
+ return tableState.columnFilters.some((filter) => filter.id === columnId);
+}
+
+/** Column filter chips in the bar (excludes the column currently owned by QS). */
+export function getPresentColumnFilterIds(
+ tableState: Pick,
+ columnFilters: DataTableColumnFilters,
+ columnVisibility: VisibilityState,
+): string[] {
+ const qsField = tableState.quickSearchField;
+ return tableState.columnFilters
+ .map((filter) => filter.id)
+ .filter((id) => {
+ if (id === qsField) return false;
+ if (!columnFilters[id]) return false;
+ if (columnVisibility[id] === false) return false;
+ return true;
+ });
+}
+
+export function upsertPresentFilterValue(
+ columnFilters: ColumnFiltersState,
+ columnId: string,
+ value: unknown,
+): ColumnFiltersState {
+ const index = columnFilters.findIndex((filter) => filter.id === columnId);
+ if (index === -1) {
+ return [...columnFilters, { id: columnId, value }];
+ }
+ return columnFilters.map((filter, i) => (i === index ? { id: columnId, value } : filter));
+}
+
+export function addPresentFilter(
+ columnFilters: ColumnFiltersState,
+ columnId: string,
+ config: ColumnFilterConfig,
+): ColumnFiltersState {
+ if (columnFilters.some((filter) => filter.id === columnId)) {
+ return columnFilters;
+ }
+ return [...columnFilters, { id: columnId, value: createDefaultFilterValue(config) }];
+}
+
+export function removePresentFilter(columnFilters: ColumnFiltersState, columnId: string): ColumnFiltersState {
+ return columnFilters.filter((filter) => filter.id !== columnId);
+}
+
+export function retargetPresentFilter(
+ columnFilters: ColumnFiltersState,
+ fromColumnId: string,
+ toColumnId: string,
+ toConfig: ColumnFilterConfig,
+): ColumnFiltersState {
+ if (fromColumnId === toColumnId) {
+ return columnFilters;
+ }
+
+ const fromIndex = columnFilters.findIndex((filter) => filter.id === fromColumnId);
+ const nextValue = { id: toColumnId, value: createDefaultFilterValue(toConfig) };
+
+ // Drop the destination if it already exists elsewhere, then replace at the original index.
+ const withoutTarget = removePresentFilter(columnFilters, toColumnId);
+ const replaceIndex = withoutTarget.findIndex((filter) => filter.id === fromColumnId);
+
+ if (fromIndex === -1 || replaceIndex === -1) {
+ return addPresentFilter(withoutTarget, toColumnId, toConfig);
+ }
+
+ return withoutTarget.map((filter, index) => (index === replaceIndex ? nextValue : filter));
+}
+
+/** Drop filters for columns that became hidden; reset QS if it targeted a hidden column. */
+export function cleanupFiltersForHiddenColumns(args: {
+ nextVisibility: VisibilityState;
+ columnFilters: ColumnFiltersState;
+ quickSearchField: string;
+ filterConfig: DataTableColumnFilters;
+}): Partial> | null {
+ const { nextVisibility, columnFilters, quickSearchField, filterConfig } = args;
+
+ const isHidden = (columnId: string) => nextVisibility[columnId] === false;
+
+ const nextColumnFilters = columnFilters.filter((filter) => {
+ if (!filterConfig[filter.id]) return true;
+ return !isHidden(filter.id);
+ });
+
+ let nextQuickSearchField = quickSearchField;
+ let nextGlobalFilter: string | undefined;
+
+ if (quickSearchField && isHidden(quickSearchField)) {
+ nextQuickSearchField = '';
+ nextGlobalFilter = '';
+ }
+
+ const filtersChanged = nextColumnFilters.length !== columnFilters.length;
+ const qsChanged = nextQuickSearchField !== quickSearchField;
+
+ if (!filtersChanged && !qsChanged) {
+ return null;
+ }
+
+ return {
+ columnFilters: nextColumnFilters,
+ quickSearchField: nextQuickSearchField,
+ ...(nextGlobalFilter !== undefined ? { globalFilter: nextGlobalFilter } : {}),
+ };
+}
+
+/** How many present filters currently have a non-inactive (active) value.
+ * Excludes the column currently owned by quick search.
+ */
+export function countActiveFilters(
+ tableState: Pick,
+ columnFilters: DataTableColumnFilters,
+): number {
+ const qsField = tableState.quickSearchField;
+ return tableState.columnFilters.reduce((count, filter) => {
+ if (filter.id === qsField) return count;
+ const config = columnFilters[filter.id];
+ if (!config) return count;
+ return isInactiveFilterValue(filter.value, config.type, config.defaultOperator) ? count : count + 1;
+ }, 0);
+}
+
+export type FilterPresenceApi = {
+ config: DataTableColumnFilters;
+ isPresent: (columnId: string) => boolean;
+ setPresent: (columnId: string, present: boolean) => void;
+};
diff --git a/src/lib/table-url-parsers.ts b/src/lib/table-url-parsers.ts
index 50de0361..5effcc1c 100644
--- a/src/lib/table-url-parsers.ts
+++ b/src/lib/table-url-parsers.ts
@@ -1,5 +1,5 @@
import type { ColumnFiltersState, SortingState, VisibilityState } from '@tanstack/react-table';
-import { createParser, parseAsInteger, parseAsString } from 'nuqs';
+import { createParser, parseAsBoolean, parseAsInteger, parseAsString } from 'nuqs';
/**
* Custom nuqs parser that encodes JSON as base64 to produce URL-safe values.
@@ -25,6 +25,8 @@ function parseAsBase64Json(validator: (value: unknown) => T | null) {
/** Single source of truth for table URL state (DataTable + sidebar view links). */
export const tableUrlParsers = {
q: parseAsString,
+ /** Quick-search field selection (`__all__` omitted — empty means search all). */
+ sf: parseAsString,
sort: parseAsBase64Json((v) => {
if (!Array.isArray(v)) return null;
return v as SortingState;
@@ -41,6 +43,8 @@ export const tableUrlParsers = {
pageSize: parseAsInteger,
view: parseAsString,
viewName: parseAsString,
+ /** Whether the filter chip bar is expanded. */
+ fe: parseAsBoolean,
} as const;
export const tableUrlNuqsOptions = {
diff --git a/src/messages/de/dashboard.json b/src/messages/de/dashboard.json
index 18eb9782..fc699ec6 100644
--- a/src/messages/de/dashboard.json
+++ b/src/messages/de/dashboard.json
@@ -503,7 +503,9 @@
"table": {
"name": "Name",
"slug": "Kurzname",
- "managers": "Verwalter*innen"
+ "slugDescription": "URL-freundlicher Kurzname des Projekts.",
+ "managers": "Verwalter*innen",
+ "managersShort": "Verwalter"
},
"new": {
"title": "Neues Projekt"
@@ -573,17 +575,24 @@
},
"table": {
"lenderNumber": "Kontonummer",
+ "lenderNumberShort": "Konto",
"type": "Typ",
"name": "Vollständiger Name",
+ "nameShort": "Name",
+ "nameDescription": "Vorname und Nachname bzw. Organisationsname der Kreditgeber*in.",
"firstName": "Vorname",
"lastName": "Nachname",
"organisationName": "Organisationsname",
+ "organisationNameShort": "Organisation",
"titlePrefix": "Titel (vorangestellt)",
+ "titlePrefixShort": "Titel vor",
"titleSuffix": "Titel (nachgestellt)",
+ "titleSuffixShort": "Titel nach",
"email": "E-Mail",
"telNo": "Telefon",
"address": "Adresse",
"street": "Straße, Hausnummer, …",
+ "streetShort": "Straße",
"addon": "Adresszusatz",
"zip": "PLZ",
"place": "Ort",
@@ -593,6 +602,8 @@
"bic": "BIC",
"salutation": "Anrede",
"notificationType": "Erhalt Kontomitteilung",
+ "notificationTypeShort": "Mitteilung",
+ "notificationTypeDescription": "Kanal, über den die Kreditgeber*in Kontomitteilungen erhält.",
"actions": "Aktionen",
"filterByName": "Nach Namen filtern..."
},
@@ -674,9 +685,11 @@
"title": "Transaktionen",
"table": {
"type": "Transaktionsart",
+ "typeShort": "Art",
"date": "Datum",
"amount": "Betrag",
- "paymentType": "Zahlungsart"
+ "paymentType": "Zahlungsart",
+ "paymentTypeShort": "Zahlung"
},
"timeRange": {
"label": "Zeitraum",
@@ -733,8 +746,10 @@
"date": "Datum",
"counterparty": "Gegenpartei",
"remittance": "Verwendungszweck",
+ "remittanceShort": "Zweck",
"amount": "Betrag",
"lender": "Kreditgeber*in",
+ "lenderShort": "Kreditgeber",
"loan": "Kredit",
"type": "Art"
},
@@ -807,18 +822,20 @@
},
"table": {
"loanNumber": "Kreditnummer",
- "loanNumberShort": "Kredit",
+ "loanNumberShort": "Kredit-Nr.",
"lenderNumber": "Kreditgeber*in Nummer",
+ "lenderNumberShort": "KG-Nr.",
"lenderName": "Kreditgeber*in",
"lender": "Kreditgeber*in",
"amount": "Vertragswert",
"balance": "Kontostand",
+ "balanceLabel": "Stand",
"total": "Summe",
"totals": "Gesamt",
- "balanceShort": "Stand:",
"interestRate": "Zinssatz",
"interest": "Zinsen",
"interestPaid": "Ausbezahlte Zinsen",
+ "interestPaidShort": "Ausbez. Zinsen",
"endDate": "Enddatum",
"status": "Status",
"type": "Typ",
@@ -834,6 +851,8 @@
"defaultAmount": "Verzugsbetrag",
"defaultReason": "Verzugsgrund",
"terminationModalities": "Kündigungsmodalitäten",
+ "terminationModalitiesShort": "Kündigung",
+ "terminationModalitiesDescription": "Enddatum, Laufzeit oder Kündigungsfrist je nach Kündigungstyp.",
"terminationType": "Kündigungstyp",
"terminationDate": "Kündigungsdatum",
"terminationPeriod": "Kündigungsfrist",
@@ -841,12 +860,17 @@
"duration": "Laufzeit",
"durationType": "Laufzeiteinheit",
"altInterestMethod": "Alternative Zinsmethode",
+ "altInterestMethodShort": "Alt. Zinsmeth.",
"contractStatus": "Vertragsstatus",
"isSavingsContract": "Ansparvertrag",
+ "isSavingsContractShort": "Ansparv.",
"signDate": "Vertragsdatum",
"repayDate": "Rückzahlungsfrist",
+ "repayDateShort": "Rückzahlung",
"loanTerm": "Kreditlaufzeit",
+ "loanTermShort": "Laufzeit",
"repaymentPeriod": "Rückzahlungszeitraum",
+ "repaymentPeriodShort": "Rückzahl.-ZR",
"repaidDate": "Rückzahlungsdatum",
"transactions": "Zahlungen",
"bookings": "Buchungen",
@@ -854,11 +878,18 @@
"notes": "Notizen",
"deposits": "Einzahlungen",
"outstandingDepositSum": "Ausstehender Geldeingang (Betrag)",
+ "outstandingDepositSumShort": "Ausstehend €",
+ "outstandingDepositSumDescription": "Summe der noch nicht eingegangenen erwarteten Einzahlungen.",
"outstandingDepositSinceDate": "Ausstehender Geldeingang (Seit Datum)",
+ "outstandingDepositSinceDateShort": "Ausstehend seit",
+ "outstandingDepositSinceDateDescription": "Datum, seit dem ein erwarteter Geldeingang aussteht.",
"outstandingDepositSinceDays": "Ausstehender Geldeingang (Seit Tagen)",
"depositsCount": "Geldeingänge (Anzahl)",
+ "depositsCountShort": "Eingänge",
"requiredDepositsCount": "Benötigte Geldeingänge (Anzahl)",
+ "requiredDepositsCountShort": "Erwartet",
"outstandingDepositsCount": "Ausstehende Geldeingänge (Anzahl)",
+ "outstandingDepositsCountShort": "Offen",
"withdrawals": "Auszahlungen",
"savingsRateType": "Einzahlungsart",
"savingsMonthlyAmount": "Monatlicher Betrag",
@@ -1053,8 +1084,11 @@
"bic": "BIC",
"country": "Land",
"userLanguage": "Benutzer*in Sprache",
+ "userLanguageShort": "Sprache",
"lenderSalutation": "Kreditgeber*in Anrede",
+ "lenderSalutationShort": "Anrede",
"lenderCountry": "Kreditgeber*in Land",
+ "lenderCountryShort": "KG-Land",
"interestMethod": "Zinsmethode"
},
"form": {
@@ -1365,6 +1399,8 @@
"table": {
"name": "Name",
"limitationType": "Bagatellgrenze",
+ "limitationTypeShort": "Grenze",
+ "limitationTypeDescription": "Art der Bagatellgrenze nach dem Vermögensanlagengesetz.",
"interestRate": "Zinssatz",
"numberOfLoans": "Kredite",
"totalAmount": "Summe",
diff --git a/src/messages/de/dataTable.json b/src/messages/de/dataTable.json
index 63ce533a..e7c5becb 100644
--- a/src/messages/de/dataTable.json
+++ b/src/messages/de/dataTable.json
@@ -68,7 +68,14 @@
"dateFilterRelative": "Letzte",
"dateFilterStart": "Startdatum",
"dateFilterEnd": "Enddatum",
- "globalFilter": "In allen Spalten suchen...",
+ "globalFilter": "Spalten durchsuchen...",
+ "globalFilterAll": "Alle",
+ "globalFilterField": "Suchfeld",
+ "filterTarget": "Filterfeld",
+ "addFilter": "Filter hinzufügen",
+ "removeFilter": "Filter entfernen",
+ "expandFilters": "Filter einblenden",
+ "collapseFilters": "Filter ausblenden",
"bulkBar": {
"selected": "{count} ausgewählt",
"deselect": "Auswahl aufheben"
@@ -77,5 +84,14 @@
"transaction": "Transaktion",
"loan": "Kredit",
"lender": "Kreditgeber*in"
+ },
+ "columnMenu": {
+ "sort": "Spalte sortieren",
+ "sortAsc": "Aufsteigend sortieren",
+ "sortDesc": "Absteigend sortieren",
+ "filter": "Filter für Spalte anzeigen",
+ "moreOptions": "Weitere Optionen",
+ "moveLeft": "Spalte nach links verschieben",
+ "moveRight": "Spalte nach rechts verschieben"
}
}
diff --git a/src/messages/de/logbook.json b/src/messages/de/logbook.json
index e21f7419..235b4fb5 100644
--- a/src/messages/de/logbook.json
+++ b/src/messages/de/logbook.json
@@ -3,6 +3,9 @@
"who": "Wer",
"when": "Wann",
"what": "Was",
+ "whoDescription": "Benutzer*in, die die Änderung vorgenommen hat.",
+ "whenDescription": "Zeitpunkt der Änderung.",
+ "whatDescription": "Beschreibung der durchgeführten Änderung.",
"changeDetails": "Änderungsdetails",
"before": "Vorher",
"after": "Nachher",
diff --git a/src/messages/de/templates.json b/src/messages/de/templates.json
index 21d405fc..927fd18d 100644
--- a/src/messages/de/templates.json
+++ b/src/messages/de/templates.json
@@ -353,7 +353,9 @@
"name": "Name",
"type": "Typ",
"dataset": "Datensatz",
- "createdAt": "Erstellt am"
+ "datasetDescription": "Datenquelle, aus der die Vorlage Inhalte bezieht.",
+ "createdAt": "Erstellt am",
+ "createdAtShort": "Erstellt"
},
"actions": {
"edit": "Bearbeiten",
diff --git a/src/types/dashboard-widgets/table-view.ts b/src/types/dashboard-widgets/table-view.ts
index bc4b11e4..979deb58 100644
--- a/src/types/dashboard-widgets/table-view.ts
+++ b/src/types/dashboard-widgets/table-view.ts
@@ -31,13 +31,14 @@ export const TABLE_VIEW_ROW_LIMIT_MAX = 100;
export const DEFAULT_TABLE_VIEW_ROW_LIMIT = 10;
export const DEFAULT_LOAN_TABLE_VISIBLE_COLUMNS = [
- 'lender.lenderNumber',
- 'lender.name',
'loanNumber',
'signDate',
+ 'lender.name',
+ 'lender.lenderNumber',
'amount',
+ 'deposits',
+ 'withdrawals',
'balance',
- 'interestRate',
'status',
] as const;
diff --git a/src/types/date-filter-value.ts b/src/types/date-filter-value.ts
index 64b87e2d..1f7b25d1 100644
--- a/src/types/date-filter-value.ts
+++ b/src/types/date-filter-value.ts
@@ -28,7 +28,7 @@ export const DATE_FILTER_LAST_UNITS = ['days', 'months'] as const;
export type DateFilterLastUnit = (typeof DATE_FILTER_LAST_UNITS)[number];
export type DateFilterRelativeAmountValue = {
- amount: number;
+ amount: number | null;
unit: DateFilterLastUnit;
};
@@ -90,7 +90,13 @@ function isDateFilterLastUnit(value: unknown): value is DateFilterLastUnit {
return typeof value === 'string' && (DATE_FILTER_LAST_UNITS as readonly string[]).includes(value);
}
-function parseRelativeAmount(value: unknown, fallback: number): number {
+function parseRelativeAmount(value: unknown, fallback: number | null): number | null {
+ if (value === null) {
+ return null;
+ }
+ if (value === undefined || value === '') {
+ return fallback;
+ }
const parsedAmount = Number(value);
return Number.isFinite(parsedAmount) ? Math.round(parsedAmount) : fallback;
}
diff --git a/src/types/number-filter-value.ts b/src/types/number-filter-value.ts
index 333feb22..ad77e312 100644
--- a/src/types/number-filter-value.ts
+++ b/src/types/number-filter-value.ts
@@ -18,12 +18,10 @@ export type NumberFilterValue =
| FilterOperatorValue<'empty'>
| FilterOperatorValue<'notEmpty'>;
-export function createDefaultNumberFilterValue(): NumberFilterValue {
- return {
- operator: 'between',
- min: null,
- max: null,
- };
+export function createDefaultNumberFilterValue(
+ defaultOperator: NumberFilterOperator = 'between',
+): NumberFilterValue {
+ return createDefaultNumberFilterValueForOperator(defaultOperator);
}
export function createDefaultNumberFilterValueForOperator(
@@ -31,7 +29,7 @@ export function createDefaultNumberFilterValueForOperator(
): NumberFilterValue {
switch (operator) {
case 'between':
- return createDefaultNumberFilterValue();
+ return { operator: 'between', min: null, max: null };
case 'eq':
case 'gt':
case 'lt':
@@ -71,19 +69,22 @@ function parseLegacyNumberRange(raw: unknown): NumberFilterValue | null {
};
}
-export function parseNumberFilterValue(raw: unknown): NumberFilterValue {
+export function parseNumberFilterValue(
+ raw: unknown,
+ defaultOperator: NumberFilterOperator = 'between',
+): NumberFilterValue {
const legacy = parseLegacyNumberRange(raw);
if (legacy) {
return legacy;
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
- return createDefaultNumberFilterValue();
+ return createDefaultNumberFilterValue(defaultOperator);
}
const value = raw as { operator?: NumberFilterOperatorWithEmpty };
if (!isNumberFilterOperator(value.operator)) {
- return createDefaultNumberFilterValue();
+ return createDefaultNumberFilterValue(defaultOperator);
}
switch (value.operator) {
@@ -113,9 +114,31 @@ export function parseNumberFilterValue(raw: unknown): NumberFilterValue {
}
}
-export function isInactiveNumberFilterValue(raw: unknown): boolean {
- const parsed = parseNumberFilterValue(raw);
- // Mirror dates: only the default operator with an empty payload is inactive.
+function hasEmptyPayload(parsed: NumberFilterValue): boolean {
+ switch (parsed.operator) {
+ case 'between':
+ return parsed.min == null && parsed.max == null;
+ case 'eq':
+ case 'gt':
+ case 'lt':
+ case 'gte':
+ case 'lte':
+ return parsed.value == null;
+ case 'empty':
+ case 'notEmpty':
+ return false;
+ }
+}
+
+export function isInactiveNumberFilterValue(
+ raw: unknown,
+ defaultOperator: NumberFilterOperator = 'between',
+): boolean {
+ const parsed = parseNumberFilterValue(raw, defaultOperator);
+ // Mirror enums/dates: only the default operator with an empty payload is inactive.
// Other operators keep the filter in state so the operator select can stick.
- return parsed.operator === 'between' && parsed.min == null && parsed.max == null;
+ if (parsed.operator !== defaultOperator) {
+ return false;
+ }
+ return hasEmptyPayload(parsed);
}