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); }