@fastnd/components 1.0.29 → 1.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/FavoriteButton/FavoriteButton.d.ts +2 -1
- package/dist/examples/data-explorer/CardCarouselPanel/CardCarouselPanel.tsx +197 -0
- package/dist/examples/data-explorer/CardView/CardView.tsx +168 -0
- package/dist/examples/data-explorer/ColumnConfigPopover/ColumnConfigPopover.tsx +157 -0
- package/dist/examples/data-explorer/DataExplorerEmpty/DataExplorerEmpty.tsx +56 -0
- package/dist/examples/data-explorer/DataExplorerPage/DataExplorerPage.tsx +101 -0
- package/dist/examples/data-explorer/DataExplorerPagination/DataExplorerPagination.tsx +129 -0
- package/dist/examples/data-explorer/DataExplorerToolbar/DataExplorerToolbar.tsx +143 -0
- package/dist/examples/data-explorer/DomainSwitcher/DomainSwitcher.tsx +36 -0
- package/dist/examples/data-explorer/ExpansionRows/ExpansionRows.tsx +180 -0
- package/dist/examples/data-explorer/FilterChip/FilterChip.tsx +85 -0
- package/dist/examples/data-explorer/FilterPopoverContent/FilterPopoverContent.tsx +73 -0
- package/dist/examples/data-explorer/ListView/ListView.tsx +305 -0
- package/dist/examples/data-explorer/MoreFiltersPopover/MoreFiltersPopover.tsx +113 -0
- package/dist/examples/data-explorer/TableView/TableView.tsx +193 -0
- package/dist/examples/data-explorer/cells/CellRenderer.tsx +147 -0
- package/dist/examples/data-explorer/cells/CurrencyCell.tsx +31 -0
- package/dist/examples/data-explorer/cells/DoubleTextCell.tsx +27 -0
- package/dist/examples/data-explorer/cells/ExpandButton.tsx +67 -0
- package/dist/examples/data-explorer/cells/InventoryBadgeCell.tsx +52 -0
- package/dist/examples/data-explorer/cells/LinkCell.tsx +42 -0
- package/dist/examples/data-explorer/cells/ScoreBar.tsx +50 -0
- package/dist/examples/data-explorer/cells/StatusBadgeCell.tsx +39 -0
- package/dist/examples/data-explorer/cells/TextCell.tsx +35 -0
- package/dist/examples/data-explorer/cells/index.ts +26 -0
- package/dist/examples/data-explorer/domains/applications.ts +225 -0
- package/dist/examples/data-explorer/domains/customers.ts +267 -0
- package/dist/examples/data-explorer/domains/index.ts +26 -0
- package/dist/examples/data-explorer/domains/products.ts +1116 -0
- package/dist/examples/data-explorer/domains/projects.ts +205 -0
- package/dist/examples/data-explorer/hooks/use-data-explorer-state.ts +371 -0
- package/dist/examples/data-explorer/index.ts +3 -0
- package/dist/examples/data-explorer/types.ts +239 -0
- package/dist/fastnd-components.js +9 -9
- package/package.json +1 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Pagination,
|
|
3
|
+
PaginationContent,
|
|
4
|
+
PaginationEllipsis,
|
|
5
|
+
PaginationItem,
|
|
6
|
+
PaginationLink,
|
|
7
|
+
PaginationNext,
|
|
8
|
+
PaginationPrevious,
|
|
9
|
+
} from '@/components/ui/pagination'
|
|
10
|
+
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
|
11
|
+
import { cn } from '@/lib/utils'
|
|
12
|
+
|
|
13
|
+
export interface DataExplorerPaginationProps {
|
|
14
|
+
currentPage: number
|
|
15
|
+
totalPages: number
|
|
16
|
+
pageSize: number
|
|
17
|
+
totalFiltered: number
|
|
18
|
+
totalData: number
|
|
19
|
+
onPageChange: (page: number) => void
|
|
20
|
+
onPageSizeChange: (size: number) => void
|
|
21
|
+
className?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PAGE_SIZES = [25, 50, 100] as const
|
|
25
|
+
|
|
26
|
+
function getPageRange(current: number, total: number): (number | '...')[] {
|
|
27
|
+
if (total <= 7) {
|
|
28
|
+
return Array.from({ length: total }, (_, i) => i + 1)
|
|
29
|
+
}
|
|
30
|
+
if (current <= 4) {
|
|
31
|
+
return [1, 2, 3, 4, 5, '...', total]
|
|
32
|
+
}
|
|
33
|
+
if (current >= total - 3) {
|
|
34
|
+
return [1, '...', total - 4, total - 3, total - 2, total - 1, total]
|
|
35
|
+
}
|
|
36
|
+
return [1, '...', current - 1, current, current + 1, '...', total]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function DataExplorerPagination({
|
|
40
|
+
currentPage,
|
|
41
|
+
totalPages,
|
|
42
|
+
pageSize,
|
|
43
|
+
totalFiltered,
|
|
44
|
+
onPageChange,
|
|
45
|
+
onPageSizeChange,
|
|
46
|
+
className,
|
|
47
|
+
}: DataExplorerPaginationProps) {
|
|
48
|
+
const startItem = totalFiltered === 0 ? 0 : (currentPage - 1) * pageSize + 1
|
|
49
|
+
const endItem = Math.min(currentPage * pageSize, totalFiltered)
|
|
50
|
+
const pageRange = getPageRange(currentPage, totalPages)
|
|
51
|
+
const atFirst = currentPage <= 1
|
|
52
|
+
const atLast = currentPage >= totalPages
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<nav
|
|
56
|
+
data-slot="data-explorer-pagination"
|
|
57
|
+
aria-label="Seitennavigation"
|
|
58
|
+
className={cn('flex flex-wrap items-center justify-between gap-3 px-4 py-3', className)}
|
|
59
|
+
>
|
|
60
|
+
{/* Left: result range + page size select */}
|
|
61
|
+
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
|
62
|
+
<span>
|
|
63
|
+
{startItem}–{endItem} von {totalFiltered} Ergebnissen
|
|
64
|
+
</span>
|
|
65
|
+
<div className="flex items-center gap-1.5">
|
|
66
|
+
<span className="text-xs">Zeilen:</span>
|
|
67
|
+
<NativeSelect
|
|
68
|
+
size="sm"
|
|
69
|
+
value={pageSize}
|
|
70
|
+
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
|
71
|
+
aria-label="Einträge pro Seite"
|
|
72
|
+
className="w-auto"
|
|
73
|
+
>
|
|
74
|
+
{PAGE_SIZES.map((s) => (
|
|
75
|
+
<NativeSelectOption key={s} value={s}>
|
|
76
|
+
{s}
|
|
77
|
+
</NativeSelectOption>
|
|
78
|
+
))}
|
|
79
|
+
</NativeSelect>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Right: page navigation */}
|
|
84
|
+
<Pagination className="w-auto mx-0 justify-end">
|
|
85
|
+
<PaginationContent>
|
|
86
|
+
<PaginationItem>
|
|
87
|
+
<PaginationPrevious
|
|
88
|
+
role="button"
|
|
89
|
+
aria-disabled={atFirst}
|
|
90
|
+
className={cn(atFirst && 'pointer-events-none opacity-50')}
|
|
91
|
+
onClick={atFirst ? undefined : () => onPageChange(currentPage - 1)}
|
|
92
|
+
/>
|
|
93
|
+
</PaginationItem>
|
|
94
|
+
|
|
95
|
+
{pageRange.map((page, idx) =>
|
|
96
|
+
page === '...' ? (
|
|
97
|
+
<PaginationItem key={`ellipsis-${idx}`}>
|
|
98
|
+
<PaginationEllipsis />
|
|
99
|
+
</PaginationItem>
|
|
100
|
+
) : (
|
|
101
|
+
<PaginationItem key={page}>
|
|
102
|
+
<PaginationLink
|
|
103
|
+
role="button"
|
|
104
|
+
isActive={page === currentPage}
|
|
105
|
+
onClick={() => onPageChange(page)}
|
|
106
|
+
aria-label={`Seite ${page}`}
|
|
107
|
+
className={page === currentPage ? 'bg-primary text-primary-foreground border-primary hover:bg-primary/90 hover:text-primary-foreground' : ''}
|
|
108
|
+
>
|
|
109
|
+
{page}
|
|
110
|
+
</PaginationLink>
|
|
111
|
+
</PaginationItem>
|
|
112
|
+
),
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
<PaginationItem>
|
|
116
|
+
<PaginationNext
|
|
117
|
+
role="button"
|
|
118
|
+
aria-disabled={atLast}
|
|
119
|
+
className={cn(atLast && 'pointer-events-none opacity-50')}
|
|
120
|
+
onClick={atLast ? undefined : () => onPageChange(currentPage + 1)}
|
|
121
|
+
/>
|
|
122
|
+
</PaginationItem>
|
|
123
|
+
</PaginationContent>
|
|
124
|
+
</Pagination>
|
|
125
|
+
</nav>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
DataExplorerPagination.displayName = 'DataExplorerPagination'
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { LayoutGrid, List, RotateCcw, Search, Table } from 'lucide-react'
|
|
3
|
+
import { Button } from '@/components/ui/button'
|
|
4
|
+
import { Input } from '@/components/ui/input'
|
|
5
|
+
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
|
6
|
+
import { ColumnConfigPopover } from '../ColumnConfigPopover/ColumnConfigPopover'
|
|
7
|
+
import { FilterChip } from '../FilterChip/FilterChip'
|
|
8
|
+
import { MoreFiltersPopover } from '../MoreFiltersPopover/MoreFiltersPopover'
|
|
9
|
+
import type { DataExplorerActions, DataExplorerDerived, DataExplorerState } from '../types'
|
|
10
|
+
import { cn } from '@/lib/utils'
|
|
11
|
+
|
|
12
|
+
export interface DataExplorerToolbarProps {
|
|
13
|
+
state: DataExplorerState
|
|
14
|
+
derived: DataExplorerDerived
|
|
15
|
+
actions: DataExplorerActions
|
|
16
|
+
className?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function DataExplorerToolbar({
|
|
20
|
+
state,
|
|
21
|
+
derived,
|
|
22
|
+
actions,
|
|
23
|
+
className,
|
|
24
|
+
}: DataExplorerToolbarProps) {
|
|
25
|
+
const secondaryFilterCount = React.useMemo(
|
|
26
|
+
() =>
|
|
27
|
+
derived.secondaryFilterColumns.reduce(
|
|
28
|
+
(acc, [key]) => acc + (state.filters[key]?.length ?? 0),
|
|
29
|
+
0,
|
|
30
|
+
),
|
|
31
|
+
[derived.secondaryFilterColumns, state.filters],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
data-slot="data-explorer-toolbar"
|
|
37
|
+
role="toolbar"
|
|
38
|
+
aria-label="Daten-Toolbar"
|
|
39
|
+
className={cn('flex flex-col gap-3 border-b bg-card px-6 py-4', className)}
|
|
40
|
+
>
|
|
41
|
+
{/* Row 1: View switcher + column config */}
|
|
42
|
+
<div className="flex items-center justify-between gap-2">
|
|
43
|
+
<ToggleGroup
|
|
44
|
+
type="single"
|
|
45
|
+
value={state.viewMode}
|
|
46
|
+
onValueChange={(v) => v && actions.switchView(v as DataExplorerState['viewMode'])}
|
|
47
|
+
variant="outline"
|
|
48
|
+
size="sm"
|
|
49
|
+
>
|
|
50
|
+
<ToggleGroupItem
|
|
51
|
+
value="table"
|
|
52
|
+
aria-label="Tabellenansicht"
|
|
53
|
+
className="gap-1.5 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:border-primary"
|
|
54
|
+
>
|
|
55
|
+
<Table className="size-3.5" />
|
|
56
|
+
<span className="hidden sm:inline">Tabelle</span>
|
|
57
|
+
</ToggleGroupItem>
|
|
58
|
+
<ToggleGroupItem
|
|
59
|
+
value="list"
|
|
60
|
+
aria-label="Listenansicht"
|
|
61
|
+
className="gap-1.5 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:border-primary"
|
|
62
|
+
>
|
|
63
|
+
<List className="size-3.5" />
|
|
64
|
+
<span className="hidden sm:inline">Liste</span>
|
|
65
|
+
</ToggleGroupItem>
|
|
66
|
+
<ToggleGroupItem
|
|
67
|
+
value="card"
|
|
68
|
+
aria-label="Kartenansicht"
|
|
69
|
+
className="gap-1.5 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:border-primary"
|
|
70
|
+
>
|
|
71
|
+
<LayoutGrid className="size-3.5" />
|
|
72
|
+
<span className="hidden sm:inline">Karten</span>
|
|
73
|
+
</ToggleGroupItem>
|
|
74
|
+
</ToggleGroup>
|
|
75
|
+
|
|
76
|
+
<ColumnConfigPopover
|
|
77
|
+
columns={derived.domainConfig.columns}
|
|
78
|
+
columnOrder={state.columnOrder}
|
|
79
|
+
columnVisibility={state.columnVisibility}
|
|
80
|
+
onToggleVisibility={actions.toggleColumnVisibility}
|
|
81
|
+
onReorder={actions.reorderColumns}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
{/* Row 2: Filter chips + search */}
|
|
86
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
87
|
+
{/* Primary filter chips */}
|
|
88
|
+
{derived.primaryFilterColumns.map(([key, col]) => (
|
|
89
|
+
<FilterChip
|
|
90
|
+
key={key}
|
|
91
|
+
columnKey={key}
|
|
92
|
+
label={col.label}
|
|
93
|
+
selectedValues={state.filters[key] ?? []}
|
|
94
|
+
options={derived.getFilterOptions(key)}
|
|
95
|
+
onToggleOption={(value) => actions.toggleFilterOption(key, value)}
|
|
96
|
+
onClear={() => actions.clearFilter(key)}
|
|
97
|
+
/>
|
|
98
|
+
))}
|
|
99
|
+
|
|
100
|
+
{/* More filters */}
|
|
101
|
+
{derived.secondaryFilterColumns.length > 0 && (
|
|
102
|
+
<MoreFiltersPopover
|
|
103
|
+
columns={derived.secondaryFilterColumns}
|
|
104
|
+
filters={state.filters}
|
|
105
|
+
getFilterOptions={derived.getFilterOptions}
|
|
106
|
+
onToggleOption={actions.toggleFilterOption}
|
|
107
|
+
onResetSecondary={actions.resetSecondaryFilters}
|
|
108
|
+
totalSecondaryCount={secondaryFilterCount}
|
|
109
|
+
/>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
{/* Reset all */}
|
|
113
|
+
{derived.hasActiveFilters && (
|
|
114
|
+
<Button
|
|
115
|
+
variant="ghost"
|
|
116
|
+
size="sm"
|
|
117
|
+
onClick={actions.resetAllFilters}
|
|
118
|
+
aria-label="Alle Filter zurücksetzen"
|
|
119
|
+
className="h-8 gap-1.5 text-sm text-muted-foreground hover:text-destructive"
|
|
120
|
+
>
|
|
121
|
+
<RotateCcw className="size-3.5" />
|
|
122
|
+
<span>Zurücksetzen</span>
|
|
123
|
+
</Button>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{/* Search input — pushed to the right */}
|
|
127
|
+
<div className="relative ml-auto min-w-[180px] max-w-xs flex-1">
|
|
128
|
+
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
129
|
+
<Input
|
|
130
|
+
type="search"
|
|
131
|
+
value={state.searchTerm}
|
|
132
|
+
onChange={(e) => actions.setSearchTerm(e.target.value)}
|
|
133
|
+
placeholder="Suchen..."
|
|
134
|
+
aria-label="Datensätze durchsuchen"
|
|
135
|
+
className="h-8 pl-8 text-sm"
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
DataExplorerToolbar.displayName = 'DataExplorerToolbar'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
2
|
+
import { DOMAIN_KEYS, DOMAIN_LABELS } from '../domains'
|
|
3
|
+
import type { DomainKey } from '../types'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
|
|
6
|
+
export interface DomainSwitcherProps {
|
|
7
|
+
activeDomain: DomainKey
|
|
8
|
+
onDomainChange: (domain: DomainKey) => void
|
|
9
|
+
className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function DomainSwitcher({ activeDomain, onDomainChange, className }: DomainSwitcherProps) {
|
|
13
|
+
return (
|
|
14
|
+
<nav data-slot="domain-switcher" className={cn('border-b bg-card', className)}>
|
|
15
|
+
<Tabs
|
|
16
|
+
value={activeDomain}
|
|
17
|
+
onValueChange={(v) => onDomainChange(v as DomainKey)}
|
|
18
|
+
className="px-6"
|
|
19
|
+
>
|
|
20
|
+
<TabsList variant="line" className="h-auto gap-0 rounded-none p-0">
|
|
21
|
+
{DOMAIN_KEYS.map((key) => (
|
|
22
|
+
<TabsTrigger
|
|
23
|
+
key={key}
|
|
24
|
+
value={key}
|
|
25
|
+
className="rounded-none px-5 py-3 text-sm font-medium data-[state=active]:font-semibold data-[state=active]:text-primary data-[state=active]:after:bg-primary"
|
|
26
|
+
>
|
|
27
|
+
{DOMAIN_LABELS[key]}
|
|
28
|
+
</TabsTrigger>
|
|
29
|
+
))}
|
|
30
|
+
</TabsList>
|
|
31
|
+
</Tabs>
|
|
32
|
+
</nav>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
DomainSwitcher.displayName = 'DomainSwitcher'
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { Plus } from 'lucide-react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
import { Badge } from '@/components/ui/badge'
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import { TableCell, TableRow } from '@/components/ui/table'
|
|
6
|
+
import { ScoreBar } from '../cells'
|
|
7
|
+
import type { ColumnDef } from '../types'
|
|
8
|
+
|
|
9
|
+
interface ExpansionRowsProps {
|
|
10
|
+
row: Record<string, unknown>
|
|
11
|
+
columnKey: string
|
|
12
|
+
column: ColumnDef
|
|
13
|
+
visibleColumns: string[]
|
|
14
|
+
columns: Record<string, ColumnDef>
|
|
15
|
+
colSpan: number
|
|
16
|
+
className?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ExpansionRows = ({
|
|
20
|
+
row,
|
|
21
|
+
columnKey,
|
|
22
|
+
column,
|
|
23
|
+
visibleColumns,
|
|
24
|
+
columns,
|
|
25
|
+
colSpan,
|
|
26
|
+
className,
|
|
27
|
+
}: ExpansionRowsProps) => {
|
|
28
|
+
const items = row[columnKey]
|
|
29
|
+
|
|
30
|
+
if (!Array.isArray(items) || items.length === 0 || !column.expandColumns) {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Build a lookup: mainColumnKey -> expandColumnDef for all expand columns that map to a visible column
|
|
35
|
+
const mapLookup: Record<string, (typeof column.expandColumns)[number]> = {}
|
|
36
|
+
for (const ec of column.expandColumns) {
|
|
37
|
+
if (ec.mapTo) {
|
|
38
|
+
mapLookup[ec.mapTo] = ec
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const title = column.expandTitleFn ? column.expandTitleFn(row) : (column.expandLabel ?? columnKey)
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<>
|
|
46
|
+
{/* Title row spanning all columns */}
|
|
47
|
+
<TableRow
|
|
48
|
+
data-slot="expansion-title-row"
|
|
49
|
+
className={cn('bg-secondary hover:bg-secondary border-b-0', className)}
|
|
50
|
+
>
|
|
51
|
+
<TableCell colSpan={colSpan} className="text-[11px] font-semibold uppercase tracking-wide px-3 py-2">
|
|
52
|
+
<div className="flex items-center gap-2">
|
|
53
|
+
<span>{title}</span>
|
|
54
|
+
<Badge variant="secondary" className="text-xs tabular-nums">
|
|
55
|
+
{items.length}
|
|
56
|
+
</Badge>
|
|
57
|
+
</div>
|
|
58
|
+
</TableCell>
|
|
59
|
+
</TableRow>
|
|
60
|
+
|
|
61
|
+
{/* One data row per nested item */}
|
|
62
|
+
{(items as Record<string, unknown>[]).map((item, idx) => (
|
|
63
|
+
<TableRow
|
|
64
|
+
key={idx}
|
|
65
|
+
data-slot="expansion-data-row"
|
|
66
|
+
className="bg-accent hover:bg-primary/[0.04]"
|
|
67
|
+
>
|
|
68
|
+
{visibleColumns.map((mainKey) => {
|
|
69
|
+
const mainCol = columns[mainKey]
|
|
70
|
+
const cellClasses = cn(
|
|
71
|
+
mainCol?.hideMobile && 'hidden sm:table-cell',
|
|
72
|
+
mainCol?.hideTablet && 'hidden lg:table-cell',
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// The expand column itself: render the add button
|
|
76
|
+
if (mainKey === columnKey) {
|
|
77
|
+
return (
|
|
78
|
+
<TableCell key={mainKey} className={cn('px-3 py-2 text-[13px]', cellClasses)}>
|
|
79
|
+
<Button
|
|
80
|
+
variant="ghost"
|
|
81
|
+
size="icon-xs"
|
|
82
|
+
aria-label="Hinzufügen"
|
|
83
|
+
onClick={(e) => e.preventDefault()}
|
|
84
|
+
>
|
|
85
|
+
<Plus />
|
|
86
|
+
</Button>
|
|
87
|
+
</TableCell>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A column that an expand column maps into
|
|
92
|
+
const ec = mapLookup[mainKey]
|
|
93
|
+
if (ec) {
|
|
94
|
+
const value = item[ec.key]
|
|
95
|
+
|
|
96
|
+
if (ec.type === 'score-bar') {
|
|
97
|
+
return (
|
|
98
|
+
<TableCell key={mainKey} className={cn('px-3 py-2 text-[13px]', cellClasses)}>
|
|
99
|
+
<ScoreBar value={typeof value === 'number' ? value : 0} />
|
|
100
|
+
</TableCell>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Delegate rendering to the main column type for structural fidelity
|
|
105
|
+
if (mainCol?.type === 'double-text' && ec.secondaryKey) {
|
|
106
|
+
const secondary = item[ec.secondaryKey]
|
|
107
|
+
return (
|
|
108
|
+
<TableCell key={mainKey} className={cn('px-3 py-2 text-[13px]', cellClasses)}>
|
|
109
|
+
<div className={cn('flex flex-col', ec.bold && 'font-medium')}>
|
|
110
|
+
<span className="text-sm">{value != null ? String(value) : ''}</span>
|
|
111
|
+
{secondary != null && (
|
|
112
|
+
<span className="text-xs text-muted-foreground">{String(secondary)}</span>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
</TableCell>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (mainCol?.type === 'status-badge' && mainCol.statusMap && value != null) {
|
|
120
|
+
const statusKey = mainCol.statusMap[String(value)] ?? 'default'
|
|
121
|
+
return (
|
|
122
|
+
<TableCell key={mainKey} className={cn('px-3 py-2 text-[13px]', cellClasses)}>
|
|
123
|
+
<Badge variant="secondary" data-status={statusKey}>
|
|
124
|
+
{String(value)}
|
|
125
|
+
</Badge>
|
|
126
|
+
</TableCell>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (mainCol?.type === 'link') {
|
|
131
|
+
return (
|
|
132
|
+
<TableCell key={mainKey} className={cn('px-3 py-2 text-[13px]', cellClasses)}>
|
|
133
|
+
<a
|
|
134
|
+
href="#"
|
|
135
|
+
className="text-sm text-primary underline-offset-2 hover:underline"
|
|
136
|
+
onClick={(e) => e.preventDefault()}
|
|
137
|
+
>
|
|
138
|
+
{value != null ? String(value) : ''}
|
|
139
|
+
</a>
|
|
140
|
+
</TableCell>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const displayValue =
|
|
145
|
+
typeof value === 'boolean'
|
|
146
|
+
? value
|
|
147
|
+
? 'Ja'
|
|
148
|
+
: 'Nein'
|
|
149
|
+
: value != null
|
|
150
|
+
? String(value)
|
|
151
|
+
: ''
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<TableCell key={mainKey} className={cn('px-3 py-2 text-[13px]', cellClasses)}>
|
|
155
|
+
<span
|
|
156
|
+
className={cn(
|
|
157
|
+
'text-sm',
|
|
158
|
+
ec.bold && 'font-medium',
|
|
159
|
+
ec.muted && 'text-muted-foreground',
|
|
160
|
+
)}
|
|
161
|
+
>
|
|
162
|
+
{displayValue}
|
|
163
|
+
</span>
|
|
164
|
+
</TableCell>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// No mapping — render empty cell to maintain column alignment
|
|
169
|
+
return <TableCell key={mainKey} className={cn('px-3 py-2 text-[13px]', cellClasses)} />
|
|
170
|
+
})}
|
|
171
|
+
</TableRow>
|
|
172
|
+
))}
|
|
173
|
+
</>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
ExpansionRows.displayName = 'ExpansionRows'
|
|
178
|
+
|
|
179
|
+
export { ExpansionRows }
|
|
180
|
+
export type { ExpansionRowsProps }
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { ChevronDown, X } from 'lucide-react'
|
|
2
|
+
import { Badge } from '@/components/ui/badge'
|
|
3
|
+
import { Button } from '@/components/ui/button'
|
|
4
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
5
|
+
import { FilterPopoverContent } from '../FilterPopoverContent/FilterPopoverContent'
|
|
6
|
+
import { cn } from '@/lib/utils'
|
|
7
|
+
|
|
8
|
+
export interface FilterChipProps {
|
|
9
|
+
columnKey: string
|
|
10
|
+
label: string
|
|
11
|
+
selectedValues: string[]
|
|
12
|
+
options: string[]
|
|
13
|
+
onToggleOption: (value: string) => void
|
|
14
|
+
onClear: () => void
|
|
15
|
+
className?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function FilterChip({
|
|
19
|
+
columnKey,
|
|
20
|
+
label,
|
|
21
|
+
selectedValues,
|
|
22
|
+
options,
|
|
23
|
+
onToggleOption,
|
|
24
|
+
onClear,
|
|
25
|
+
className,
|
|
26
|
+
}: FilterChipProps) {
|
|
27
|
+
const hasSelection = selectedValues.length > 0
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Popover>
|
|
31
|
+
<div data-slot="filter-chip" className={cn('inline-flex items-center', className)}>
|
|
32
|
+
<PopoverTrigger asChild>
|
|
33
|
+
<Button
|
|
34
|
+
variant="outline"
|
|
35
|
+
size="sm"
|
|
36
|
+
aria-haspopup="dialog"
|
|
37
|
+
aria-label={`${label} Filter`}
|
|
38
|
+
className={cn(
|
|
39
|
+
'h-8 gap-1.5 text-sm font-medium',
|
|
40
|
+
hasSelection && 'rounded-r-none border-r-0 border-primary bg-primary/5 text-primary hover:bg-primary/10',
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
<span>{label}</span>
|
|
44
|
+
{hasSelection ? (
|
|
45
|
+
<Badge variant="secondary" className="h-4 min-w-4 px-1 py-0 text-[10px]">
|
|
46
|
+
{selectedValues.length}
|
|
47
|
+
</Badge>
|
|
48
|
+
) : (
|
|
49
|
+
<ChevronDown className="size-3.5 text-muted-foreground" />
|
|
50
|
+
)}
|
|
51
|
+
</Button>
|
|
52
|
+
</PopoverTrigger>
|
|
53
|
+
|
|
54
|
+
{hasSelection && (
|
|
55
|
+
<Button
|
|
56
|
+
variant="outline"
|
|
57
|
+
size="sm"
|
|
58
|
+
onClick={onClear}
|
|
59
|
+
aria-label={`${label} Filter entfernen`}
|
|
60
|
+
className="h-8 rounded-l-none border-primary bg-primary/5 px-1.5 text-primary hover:bg-primary/10 hover:text-destructive"
|
|
61
|
+
>
|
|
62
|
+
<X className="size-3.5" />
|
|
63
|
+
</Button>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<PopoverContent
|
|
68
|
+
align="start"
|
|
69
|
+
sideOffset={4}
|
|
70
|
+
role="dialog"
|
|
71
|
+
aria-label={`${label} Filter`}
|
|
72
|
+
className="w-64 p-3"
|
|
73
|
+
data-column-key={columnKey}
|
|
74
|
+
>
|
|
75
|
+
<FilterPopoverContent
|
|
76
|
+
options={options}
|
|
77
|
+
selectedValues={selectedValues}
|
|
78
|
+
onToggleOption={onToggleOption}
|
|
79
|
+
/>
|
|
80
|
+
</PopoverContent>
|
|
81
|
+
</Popover>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
FilterChip.displayName = 'FilterChip'
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Search } from 'lucide-react'
|
|
3
|
+
import { Checkbox } from '@/components/ui/checkbox'
|
|
4
|
+
import { Input } from '@/components/ui/input'
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
|
|
7
|
+
export interface FilterPopoverContentProps {
|
|
8
|
+
options: string[]
|
|
9
|
+
selectedValues: string[]
|
|
10
|
+
onToggleOption: (value: string) => void
|
|
11
|
+
labelMap?: Record<string, string>
|
|
12
|
+
className?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function FilterPopoverContent({
|
|
16
|
+
options,
|
|
17
|
+
selectedValues,
|
|
18
|
+
onToggleOption,
|
|
19
|
+
labelMap,
|
|
20
|
+
className,
|
|
21
|
+
}: FilterPopoverContentProps) {
|
|
22
|
+
const [search, setSearch] = React.useState('')
|
|
23
|
+
|
|
24
|
+
const filtered = React.useMemo(() => {
|
|
25
|
+
if (!search) return options
|
|
26
|
+
const lower = search.toLowerCase()
|
|
27
|
+
return options.filter((opt) => {
|
|
28
|
+
const display = labelMap?.[opt] ?? opt
|
|
29
|
+
return display.toLowerCase().includes(lower)
|
|
30
|
+
})
|
|
31
|
+
}, [options, search, labelMap])
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div data-slot="filter-popover-content" className={cn('flex flex-col gap-2', className)}>
|
|
35
|
+
<div className="relative">
|
|
36
|
+
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
37
|
+
<Input
|
|
38
|
+
value={search}
|
|
39
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
40
|
+
placeholder="Suchen..."
|
|
41
|
+
className="h-8 pl-8 text-sm"
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
<div className="max-h-60 overflow-y-auto" role="listbox">
|
|
45
|
+
{filtered.length === 0 ? (
|
|
46
|
+
<p className="py-4 text-center text-xs text-muted-foreground">Keine Treffer</p>
|
|
47
|
+
) : (
|
|
48
|
+
filtered.map((opt) => {
|
|
49
|
+
const label = labelMap?.[opt] ?? opt
|
|
50
|
+
const checked = selectedValues.includes(opt)
|
|
51
|
+
return (
|
|
52
|
+
<label
|
|
53
|
+
key={opt}
|
|
54
|
+
role="option"
|
|
55
|
+
aria-selected={checked}
|
|
56
|
+
className="flex cursor-pointer items-center gap-2.5 rounded px-1 py-1.5 text-sm hover:bg-accent"
|
|
57
|
+
>
|
|
58
|
+
<Checkbox
|
|
59
|
+
checked={checked}
|
|
60
|
+
onCheckedChange={() => onToggleOption(opt)}
|
|
61
|
+
aria-label={label}
|
|
62
|
+
/>
|
|
63
|
+
<span className="leading-snug">{label}</span>
|
|
64
|
+
</label>
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
FilterPopoverContent.displayName = 'FilterPopoverContent'
|