@fastnd/components 1.0.31 → 1.0.33

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.
Files changed (29) hide show
  1. package/dist/components/QuickAccessCard/QuickAccessCard.d.ts +12 -0
  2. package/dist/components/ScoreBar/ScoreBar.d.ts +8 -0
  3. package/dist/components/index.d.ts +2 -0
  4. package/dist/examples/dashboard/DashboardPage/DashboardPage.tsx +1 -1
  5. package/dist/examples/dashboard/ProjectList/ProjectList.tsx +87 -47
  6. package/dist/examples/dashboard/StatusDonutChart/StatusDonutChart.tsx +41 -56
  7. package/dist/examples/dashboard/constants.ts +20 -6
  8. package/dist/examples/dashboard/types.ts +2 -0
  9. package/dist/examples/data-visualization/CardCarouselPanel/CardCarouselPanel.tsx +171 -0
  10. package/dist/examples/data-visualization/CellRenderers/CellRenderers.tsx +171 -0
  11. package/dist/examples/data-visualization/ColumnConfigPopover/ColumnConfigPopover.tsx +124 -0
  12. package/dist/examples/data-visualization/DataCardView/DataCardView.tsx +223 -0
  13. package/dist/examples/data-visualization/DataExplorerPagination/DataExplorerPagination.tsx +143 -0
  14. package/dist/examples/data-visualization/DataExplorerToolbar/DataExplorerToolbar.tsx +88 -0
  15. package/dist/examples/data-visualization/DataListView/DataListView.tsx +283 -0
  16. package/dist/examples/data-visualization/DataTableView/DataTableView.tsx +455 -0
  17. package/dist/examples/data-visualization/DataVisualizationPage/DataVisualizationPage.tsx +151 -0
  18. package/dist/examples/data-visualization/FilterChipGroup/FilterChipGroup.tsx +125 -0
  19. package/dist/examples/data-visualization/MoreFiltersPopover/MoreFiltersPopover.tsx +132 -0
  20. package/dist/examples/data-visualization/constants.ts +587 -0
  21. package/dist/examples/data-visualization/hooks/use-column-config.ts +76 -0
  22. package/dist/examples/data-visualization/hooks/use-data-explorer-state.ts +318 -0
  23. package/dist/examples/data-visualization/index.ts +1 -0
  24. package/dist/examples/data-visualization/types.ts +110 -0
  25. package/dist/examples/quickaccess/QuickAccess/QuickAccess.tsx +97 -0
  26. package/dist/examples/quickaccess/index.ts +2 -0
  27. package/dist/examples/quickaccess/types.ts +11 -0
  28. package/dist/fastnd-components.js +5708 -5590
  29. package/package.json +1 -1
@@ -0,0 +1,171 @@
1
+ import React from 'react'
2
+ import { ChevronDown, ArrowLeftRight, Sparkles } from 'lucide-react'
3
+ import { Badge } from '@/components/ui/badge'
4
+ import { Button } from '@/components/ui/button'
5
+ import { ScoreBar } from '@/components/ScoreBar/ScoreBar'
6
+ import { FavoriteButton } from '@/components/FavoriteButton/FavoriteButton'
7
+ import { cn } from '@/lib/utils'
8
+ import type { ColumnDef, RenderCellOptions } from '../types'
9
+
10
+ export type { RenderCellOptions }
11
+
12
+ const STATUS_COLORS: Record<string, string> = {
13
+ active: 'bg-[var(--lifecycle-active-bg)] text-[var(--lifecycle-active)] border-transparent',
14
+ nrnd: 'bg-[var(--lifecycle-nrnd-bg)] text-[var(--lifecycle-nrnd)] border-transparent',
15
+ eol: 'bg-[var(--lifecycle-eol-bg)] text-[var(--lifecycle-eol)] border-transparent',
16
+ production: 'bg-[var(--lifecycle-production-bg)] text-[var(--lifecycle-production)] border-transparent',
17
+ }
18
+
19
+ const INVENTORY_COLORS: Record<string, string> = {
20
+ high: 'bg-[var(--inventory-high-bg)] text-[var(--inventory-high)] border-transparent',
21
+ medium: 'bg-[var(--inventory-medium-bg)] text-[var(--inventory-medium)] border-transparent',
22
+ low: 'bg-[var(--inventory-low-bg)] text-[var(--inventory-low)] border-transparent',
23
+ }
24
+
25
+ const EXPAND_ICONS: Record<string, React.ElementType> = {
26
+ arrowLeftRight: ArrowLeftRight,
27
+ sparkles: Sparkles,
28
+ }
29
+
30
+ export function renderCell(
31
+ colKey: string,
32
+ col: ColumnDef,
33
+ row: Record<string, unknown>,
34
+ options: RenderCellOptions = {},
35
+ ): React.ReactNode {
36
+ const { mode = 'default', isExpanded = false, isFavorite = false, onToggleExpand, onToggleFavorite } = options
37
+ const val = row[colKey]
38
+
39
+ if (col.render) return col.render(val, row, options)
40
+
41
+ switch (col.type) {
42
+ case 'text': {
43
+ // col.rowLines drives the clamp; default 2 so columns can shrink without growing rows
44
+ const clamp = col.rowLines === 3 ? 'line-clamp-3' : 'line-clamp-2'
45
+ return (
46
+ <span className={cn(clamp, 'text-[13px]')}>
47
+ {val != null ? String(val) : ''}
48
+ </span>
49
+ )
50
+ }
51
+
52
+ case 'double-text': {
53
+ const secondary = col.secondary != null ? row[col.secondary] : undefined
54
+ return (
55
+ <>
56
+ <span className="font-medium text-[13px] line-clamp-2">
57
+ {val != null ? String(val) : ''}
58
+ </span>
59
+ <span className="text-muted-foreground text-xs line-clamp-1">
60
+ {secondary != null ? String(secondary) : ''}
61
+ </span>
62
+ </>
63
+ )
64
+ }
65
+
66
+ case 'link': {
67
+ return (
68
+ <a
69
+ href="#"
70
+ className="text-primary hover:underline font-medium text-[13px] line-clamp-3"
71
+ onClick={(e) => e.preventDefault()}
72
+ >
73
+ {val != null ? String(val) : ''}
74
+ </a>
75
+ )
76
+ }
77
+
78
+ case 'status-badge': {
79
+ if (val == null || !col.statusMap) return <span className="text-[13px]">{val != null ? String(val) : ''}</span>
80
+ const status = col.statusMap[String(val)] ?? 'active'
81
+ return (
82
+ <Badge className={STATUS_COLORS[status]}>
83
+ {String(val)}
84
+ </Badge>
85
+ )
86
+ }
87
+
88
+ case 'inventory': {
89
+ if (val == null || !col.levelFn) return null
90
+ const level = col.levelFn(Number(val))
91
+ if (mode === 'inventory-label') {
92
+ const label = col.labelMap?.[level] ?? level
93
+ return (
94
+ <Badge className={cn(INVENTORY_COLORS[level])}>
95
+ {label}
96
+ </Badge>
97
+ )
98
+ }
99
+ const formatted = col.formatFn ? col.formatFn(Number(val)) : String(val)
100
+ return (
101
+ <Badge className={cn(INVENTORY_COLORS[level])}>
102
+ {formatted}
103
+ </Badge>
104
+ )
105
+ }
106
+
107
+ case 'currency': {
108
+ if (val == null) return null
109
+ const currency = col.currencyField != null ? String(row[col.currencyField] ?? '') : ''
110
+ const formatted = Number(val).toLocaleString('de-DE', {
111
+ minimumFractionDigits: 2,
112
+ maximumFractionDigits: 2,
113
+ })
114
+ return (
115
+ <span className="tabular-nums font-medium text-[13px] whitespace-nowrap">
116
+ {formatted} {currency}
117
+ </span>
118
+ )
119
+ }
120
+
121
+ case 'score-bar': {
122
+ return <ScoreBar value={val != null ? Number(val) : 0} />
123
+ }
124
+
125
+ case 'favorite': {
126
+ const itemName = String(row['name'] ?? row['title'] ?? '')
127
+ return (
128
+ <FavoriteButton
129
+ pressed={isFavorite}
130
+ itemName={itemName}
131
+ onPressedChange={() => onToggleFavorite?.()}
132
+ />
133
+ )
134
+ }
135
+
136
+ case 'expand': {
137
+ const items = val as unknown[]
138
+ const count = Array.isArray(items) ? items.length : 0
139
+ if (count === 0) {
140
+ return <span className="text-[13px] text-muted-foreground">—</span>
141
+ }
142
+
143
+ const ExpandIcon = col.expandIcon ? EXPAND_ICONS[col.expandIcon] : null
144
+
145
+ return (
146
+ <Button
147
+ variant="outline"
148
+ size="sm"
149
+ aria-expanded={isExpanded}
150
+ aria-label={col.expandLabel ?? colKey}
151
+ onClick={onToggleExpand}
152
+ className={cn(
153
+ 'gap-1.5',
154
+ isExpanded && 'border-primary text-primary bg-primary/5',
155
+ )}
156
+ >
157
+ {ExpandIcon && <ExpandIcon className="size-3.5" aria-hidden="true" />}
158
+ {count}
159
+ <ChevronDown
160
+ className={cn('size-3.5 transition-transform duration-200', isExpanded && 'rotate-180')}
161
+ aria-hidden="true"
162
+ />
163
+ </Button>
164
+ )
165
+ }
166
+
167
+ default: {
168
+ return <span className="text-[13px]">{val != null ? String(val) : ''}</span>
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,124 @@
1
+ import React from 'react'
2
+ import { Settings, GripVertical } from 'lucide-react'
3
+ import {
4
+ DndContext,
5
+ closestCenter,
6
+ DragEndEvent,
7
+ } from '@dnd-kit/core'
8
+ import {
9
+ SortableContext,
10
+ verticalListSortingStrategy,
11
+ useSortable,
12
+ } from '@dnd-kit/sortable'
13
+ import { CSS } from '@dnd-kit/utilities'
14
+ import { restrictToVerticalAxis } from '@dnd-kit/modifiers'
15
+ import { Button } from '@/components/ui/button'
16
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
17
+ import { Switch } from '@/components/ui/switch'
18
+ import { cn } from '@/lib/utils'
19
+ import type { ColumnDef } from '../types'
20
+
21
+ export interface ColumnConfigPopoverProps {
22
+ columns: Record<string, ColumnDef>
23
+ columnOrder: string[]
24
+ columnVisibility: Record<string, boolean>
25
+ onReorderColumn: (activeId: string, overId: string) => void
26
+ onToggleVisibility: (key: string) => void
27
+ className?: string
28
+ }
29
+
30
+ interface SortableColumnItemProps {
31
+ colKey: string
32
+ label: string
33
+ visible: boolean
34
+ onToggle: (key: string) => void
35
+ }
36
+
37
+ function SortableColumnItem({ colKey, label, visible, onToggle }: SortableColumnItemProps) {
38
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
39
+ id: colKey,
40
+ })
41
+
42
+ const style = {
43
+ transform: CSS.Transform.toString(transform),
44
+ transition,
45
+ }
46
+
47
+ return (
48
+ <div
49
+ ref={setNodeRef}
50
+ style={style}
51
+ className={cn(
52
+ 'flex items-center gap-2 px-3 py-2 rounded-md',
53
+ isDragging && 'bg-accent shadow-md z-10 relative',
54
+ )}
55
+ >
56
+ <button
57
+ {...attributes}
58
+ {...listeners}
59
+ className="cursor-grab active:cursor-grabbing text-muted-foreground shrink-0"
60
+ aria-label={`${label} verschieben`}
61
+ tabIndex={0}
62
+ >
63
+ <GripVertical className="size-4" />
64
+ </button>
65
+ <span className="flex-1 text-sm">{label}</span>
66
+ <Switch
67
+ checked={visible}
68
+ onCheckedChange={() => onToggle(colKey)}
69
+ aria-label={visible ? 'Spalte ausblenden' : 'Spalte einblenden'}
70
+ />
71
+ </div>
72
+ )
73
+ }
74
+
75
+ export function ColumnConfigPopover({
76
+ columns,
77
+ columnOrder,
78
+ columnVisibility,
79
+ onReorderColumn,
80
+ onToggleVisibility,
81
+ className,
82
+ }: ColumnConfigPopoverProps) {
83
+ function handleDragEnd(event: DragEndEvent) {
84
+ const { active, over } = event
85
+ if (over && active.id !== over.id) {
86
+ onReorderColumn(active.id as string, over.id as string)
87
+ }
88
+ }
89
+
90
+ return (
91
+ <Popover>
92
+ <PopoverTrigger asChild>
93
+ <Button variant="outline" size="sm" className={className}>
94
+ <Settings className="size-4" />
95
+ <span className="max-sm:hidden">Konfigurieren</span>
96
+ </Button>
97
+ </PopoverTrigger>
98
+ <PopoverContent className="w-64 p-0" align="end">
99
+ <div className="p-3 border-b border-border">
100
+ <span className="text-sm font-semibold">Spalten konfigurieren</span>
101
+ </div>
102
+ <DndContext
103
+ collisionDetection={closestCenter}
104
+ onDragEnd={handleDragEnd}
105
+ modifiers={[restrictToVerticalAxis]}
106
+ >
107
+ <SortableContext items={columnOrder} strategy={verticalListSortingStrategy}>
108
+ <div className="p-1">
109
+ {columnOrder.map((key) => (
110
+ <SortableColumnItem
111
+ key={key}
112
+ colKey={key}
113
+ label={columns[key]?.label ?? key}
114
+ visible={columnVisibility[key] ?? true}
115
+ onToggle={onToggleVisibility}
116
+ />
117
+ ))}
118
+ </div>
119
+ </SortableContext>
120
+ </DndContext>
121
+ </PopoverContent>
122
+ </Popover>
123
+ )
124
+ }
@@ -0,0 +1,223 @@
1
+ import React, { useLayoutEffect, useRef } from 'react'
2
+ import { FavoriteButton } from '@/components/FavoriteButton/FavoriteButton'
3
+ import { renderCell } from '../CellRenderers/CellRenderers'
4
+ import { CardCarouselPanel } from '../CardCarouselPanel/CardCarouselPanel'
5
+ import { cn } from '@/lib/utils'
6
+ import type { CardLayout, ColumnDef } from '../types'
7
+
8
+ export interface DataCardViewProps {
9
+ data: Record<string, unknown>[]
10
+ columns: Record<string, ColumnDef>
11
+ layout: CardLayout
12
+ expandedRows: Set<string>
13
+ onToggleExpansion: (rowId: string, field: string) => void
14
+ favorites: Set<string>
15
+ onToggleFavorite: (id: string) => void
16
+ className?: string
17
+ }
18
+
19
+ function getExpandFields(layout: CardLayout): string[] {
20
+ if (layout.expandFields && layout.expandFields.length > 0) return layout.expandFields
21
+ if (layout.expandField) return [layout.expandField]
22
+ return []
23
+ }
24
+
25
+ function positionCarouselPanels(
26
+ gridEl: HTMLElement,
27
+ data: Record<string, unknown>[],
28
+ expandedRows: Set<string>,
29
+ expandFields: string[],
30
+ ): void {
31
+ const colWidths = getComputedStyle(gridEl).gridTemplateColumns.split(' ')
32
+ const numCols = colWidths.length
33
+
34
+ // First pass: which logical rows (0-indexed) have at least one expanded card?
35
+ const expandedInLogicalRow = new Map<number, boolean>()
36
+ data.forEach((row, idx) => {
37
+ const logicalRow = Math.floor(idx / numCols)
38
+ if (expandFields.some((f) => expandedRows.has(`${row.id}::${f}`))) {
39
+ expandedInLogicalRow.set(logicalRow, true)
40
+ }
41
+ })
42
+
43
+ // Second pass: assign explicit grid positions to card wrappers and panels
44
+ let gridRow = 1
45
+ let col = 1
46
+ const children = Array.from(gridEl.children) as HTMLElement[]
47
+ let childIdx = 0
48
+
49
+ data.forEach((row, idx) => {
50
+ // Card wrapper
51
+ const cardWrapperEl = children[childIdx++]
52
+ if (cardWrapperEl) {
53
+ cardWrapperEl.style.gridRow = String(gridRow)
54
+ cardWrapperEl.style.gridColumn = String(col)
55
+ }
56
+
57
+ // Panel wrappers (one per expanded field)
58
+ expandFields.forEach((f) => {
59
+ if (expandedRows.has(`${row.id}::${f}`)) {
60
+ const panelEl = children[childIdx++]
61
+ if (panelEl) {
62
+ panelEl.style.gridRow = String(gridRow + 1)
63
+ panelEl.style.gridColumn = '1 / -1'
64
+ }
65
+ }
66
+ })
67
+
68
+ col++
69
+ if (col > numCols) {
70
+ col = 1
71
+ const logicalRow = Math.floor(idx / numCols)
72
+ gridRow += expandedInLogicalRow.has(logicalRow) ? 2 : 1
73
+ }
74
+ })
75
+ }
76
+
77
+ function DataCardView({
78
+ data,
79
+ columns,
80
+ layout,
81
+ expandedRows,
82
+ onToggleExpansion,
83
+ favorites,
84
+ onToggleFavorite,
85
+ className,
86
+ }: DataCardViewProps) {
87
+ const gridRef = useRef<HTMLDivElement>(null)
88
+ const expandFields = getExpandFields(layout)
89
+
90
+ useLayoutEffect(() => {
91
+ if (gridRef.current) {
92
+ positionCarouselPanels(gridRef.current, data, expandedRows, expandFields)
93
+ }
94
+ })
95
+
96
+ return (
97
+ <div
98
+ ref={gridRef}
99
+ data-slot="data-card-view"
100
+ className={cn('grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4', className)}
101
+ >
102
+ {data.map((row) => {
103
+ const rowId = String(row.id ?? '')
104
+ const isAnyExpanded = expandFields.some((f) => expandedRows.has(`${rowId}::${f}`))
105
+ const titleValue = row[layout.titleField]
106
+ const subtitleValue = layout.subtitleField ? row[layout.subtitleField] : undefined
107
+
108
+ return (
109
+ <React.Fragment key={rowId}>
110
+ {/* Card wrapper — grid position assigned by positionCarouselPanels */}
111
+ <div className="flex flex-col">
112
+ <div
113
+ className={cn(
114
+ 'flex flex-col flex-1 border border-border rounded-lg overflow-hidden hover:shadow-md transition-shadow bg-card text-card-foreground',
115
+ isAnyExpanded && 'border-b-2 border-b-primary',
116
+ )}
117
+ >
118
+ {/* Header */}
119
+ <div className="p-4 pb-0 flex items-start justify-between gap-2">
120
+ <div className="flex-1 min-w-0">
121
+ <div className="text-sm font-semibold line-clamp-2 min-h-[calc(2*1.4em)]">
122
+ {titleValue != null ? String(titleValue) : ''}
123
+ </div>
124
+ {subtitleValue != null && (
125
+ <div className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
126
+ {String(subtitleValue)}
127
+ </div>
128
+ )}
129
+ </div>
130
+ <FavoriteButton
131
+ pressed={favorites.has(rowId)}
132
+ itemName={titleValue != null ? String(titleValue) : ''}
133
+ onPressedChange={() => onToggleFavorite(rowId)}
134
+ />
135
+ </div>
136
+
137
+ {/* Badges */}
138
+ {layout.badgeFields.length > 0 && (
139
+ <div className="px-4 pt-2 flex gap-1.5 flex-wrap">
140
+ {layout.badgeFields.map((bf) => {
141
+ const col = columns[bf]
142
+ if (!col || row[bf] == null) return null
143
+ return (
144
+ <React.Fragment key={bf}>
145
+ {renderCell(bf, col, row)}
146
+ </React.Fragment>
147
+ )
148
+ })}
149
+ </div>
150
+ )}
151
+
152
+ {/* Rows */}
153
+ {layout.rows.length > 0 && (
154
+ <div className="px-4 pt-3 flex flex-col gap-1.5">
155
+ {layout.rows.map((r) => {
156
+ const col = columns[r.field]
157
+ const valueNode = col
158
+ ? renderCell(
159
+ r.field,
160
+ col,
161
+ row,
162
+ r.rendererOverride === 'inventory-label' ? { mode: 'inventory-label' } : {},
163
+ )
164
+ : String(row[r.field] ?? '')
165
+ return (
166
+ <div key={r.field} className="flex justify-between items-start gap-4">
167
+ <span className="text-xs text-muted-foreground shrink-0">{r.label}</span>
168
+ <span className="text-[13px] font-medium min-w-0 text-right">{valueNode}</span>
169
+ </div>
170
+ )
171
+ })}
172
+ </div>
173
+ )}
174
+
175
+ {/* Footer — expand buttons */}
176
+ {expandFields.length > 0 && (
177
+ <div className="mt-auto p-4 pt-3 border-t border-border">
178
+ <div className="flex gap-2">
179
+ {expandFields.map((ef) => {
180
+ const col = columns[ef]
181
+ if (!col) return null
182
+ const expansionKey = `${rowId}::${ef}`
183
+ return (
184
+ <React.Fragment key={ef}>
185
+ {renderCell(ef, col, row, {
186
+ isExpanded: expandedRows.has(expansionKey),
187
+ onToggleExpand: () => onToggleExpansion(rowId, ef),
188
+ })}
189
+ </React.Fragment>
190
+ )
191
+ })}
192
+ </div>
193
+ </div>
194
+ )}
195
+ </div>
196
+ </div>
197
+
198
+ {/* Carousel panels — one per expanded field, grid position assigned by positionCarouselPanels */}
199
+ {expandFields.map((ef) => {
200
+ if (!expandedRows.has(`${rowId}::${ef}`)) return null
201
+ const col = columns[ef]
202
+ if (!col || !col.expandColumns) return null
203
+ const items = row[ef]
204
+ return (
205
+ <div key={`${rowId}::${ef}::panel`}>
206
+ <CardCarouselPanel
207
+ title={col.expandTitleFn?.(row) ?? col.expandLabel ?? col.label}
208
+ items={Array.isArray(items) ? (items as Record<string, unknown>[]) : []}
209
+ expandColumns={col.expandColumns}
210
+ columns={columns}
211
+ layout={layout}
212
+ />
213
+ </div>
214
+ )
215
+ })}
216
+ </React.Fragment>
217
+ )
218
+ })}
219
+ </div>
220
+ )
221
+ }
222
+
223
+ export { DataCardView }
@@ -0,0 +1,143 @@
1
+ import React from 'react'
2
+ import { cn } from '@/lib/utils'
3
+ import {
4
+ Pagination,
5
+ PaginationContent,
6
+ PaginationEllipsis,
7
+ PaginationItem,
8
+ PaginationLink,
9
+ PaginationNext,
10
+ PaginationPrevious,
11
+ } from '@/components/ui/pagination'
12
+ import {
13
+ Select,
14
+ SelectContent,
15
+ SelectItem,
16
+ SelectTrigger,
17
+ SelectValue,
18
+ } from '@/components/ui/select'
19
+
20
+ export interface DataExplorerPaginationProps {
21
+ currentPage: number
22
+ totalPages: number
23
+ pageSize: number
24
+ totalFiltered: number
25
+ resultLabel: string
26
+ onPageChange: (page: number) => void
27
+ onPageSizeChange: (size: number) => void
28
+ className?: string
29
+ }
30
+
31
+ function getPageRange(current: number, total: number): (number | null)[] {
32
+ if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
33
+ const pages: (number | null)[] = []
34
+ if (current <= 4) {
35
+ for (let i = 1; i <= 5; i++) pages.push(i)
36
+ pages.push(null, total)
37
+ } else if (current >= total - 3) {
38
+ pages.push(1, null)
39
+ for (let i = total - 4; i <= total; i++) pages.push(i)
40
+ } else {
41
+ pages.push(1, null, current - 1, current, current + 1, null, total)
42
+ }
43
+ return pages
44
+ }
45
+
46
+ export function DataExplorerPagination({
47
+ currentPage,
48
+ totalPages,
49
+ pageSize,
50
+ totalFiltered,
51
+ resultLabel,
52
+ onPageChange,
53
+ onPageSizeChange,
54
+ className,
55
+ }: DataExplorerPaginationProps) {
56
+ const start = totalFiltered === 0 ? 0 : (currentPage - 1) * pageSize + 1
57
+ const end = Math.min(currentPage * pageSize, totalFiltered)
58
+ const pageRange = getPageRange(currentPage, totalPages)
59
+ const atFirst = currentPage <= 1
60
+ const atLast = currentPage >= totalPages
61
+
62
+ return (
63
+ <div
64
+ data-slot="data-explorer-pagination"
65
+ className={cn(
66
+ 'flex items-center justify-between border-t border-border px-6 py-3',
67
+ className
68
+ )}
69
+ >
70
+ {/* Left: info + page size */}
71
+ <div className="flex items-center gap-3 text-xs text-muted-foreground">
72
+ <span>
73
+ {start}–{end} von {totalFiltered} {resultLabel}
74
+ </span>
75
+ <Select
76
+ value={String(pageSize)}
77
+ onValueChange={(v) => onPageSizeChange(Number(v))}
78
+ >
79
+ <SelectTrigger aria-label="Zeilen pro Seite" className="h-7 w-16 text-xs">
80
+ <SelectValue />
81
+ </SelectTrigger>
82
+ <SelectContent>
83
+ <SelectItem value="25">25</SelectItem>
84
+ <SelectItem value="50">50</SelectItem>
85
+ <SelectItem value="100">100</SelectItem>
86
+ </SelectContent>
87
+ </Select>
88
+ </div>
89
+
90
+ {/* Right: page navigation */}
91
+ <Pagination className="w-auto mx-0">
92
+ <PaginationContent>
93
+ <PaginationItem>
94
+ <PaginationPrevious
95
+ href="#"
96
+ aria-disabled={atFirst}
97
+ tabIndex={atFirst ? -1 : undefined}
98
+ className={cn(atFirst && 'pointer-events-none opacity-50')}
99
+ onClick={(e) => {
100
+ e.preventDefault()
101
+ if (!atFirst) onPageChange(currentPage - 1)
102
+ }}
103
+ />
104
+ </PaginationItem>
105
+
106
+ {pageRange.map((page, idx) =>
107
+ page === null ? (
108
+ <PaginationItem key={`ellipsis-${idx}`}>
109
+ <PaginationEllipsis />
110
+ </PaginationItem>
111
+ ) : (
112
+ <PaginationItem key={page}>
113
+ <PaginationLink
114
+ href="#"
115
+ isActive={page === currentPage}
116
+ onClick={(e) => {
117
+ e.preventDefault()
118
+ onPageChange(page)
119
+ }}
120
+ >
121
+ {page}
122
+ </PaginationLink>
123
+ </PaginationItem>
124
+ )
125
+ )}
126
+
127
+ <PaginationItem>
128
+ <PaginationNext
129
+ href="#"
130
+ aria-disabled={atLast}
131
+ tabIndex={atLast ? -1 : undefined}
132
+ className={cn(atLast && 'pointer-events-none opacity-50')}
133
+ onClick={(e) => {
134
+ e.preventDefault()
135
+ if (!atLast) onPageChange(currentPage + 1)
136
+ }}
137
+ />
138
+ </PaginationItem>
139
+ </PaginationContent>
140
+ </Pagination>
141
+ </div>
142
+ )
143
+ }