@fastnd/components 1.0.31 → 1.0.32
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/QuickAccessCard/QuickAccessCard.d.ts +12 -0
- package/dist/components/ScoreBar/ScoreBar.d.ts +8 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/examples/dashboard/StatusDonutChart/StatusDonutChart.tsx +41 -56
- package/dist/examples/data-visualization/CardCarouselPanel/CardCarouselPanel.tsx +171 -0
- package/dist/examples/data-visualization/CellRenderers/CellRenderers.tsx +175 -0
- package/dist/examples/data-visualization/ColumnConfigPopover/ColumnConfigPopover.tsx +124 -0
- package/dist/examples/data-visualization/DataCardView/DataCardView.tsx +223 -0
- package/dist/examples/data-visualization/DataExplorerPagination/DataExplorerPagination.tsx +143 -0
- package/dist/examples/data-visualization/DataExplorerToolbar/DataExplorerToolbar.tsx +88 -0
- package/dist/examples/data-visualization/DataListView/DataListView.tsx +246 -0
- package/dist/examples/data-visualization/DataTableView/DataTableView.tsx +449 -0
- package/dist/examples/data-visualization/DataVisualizationPage/DataVisualizationPage.tsx +140 -0
- package/dist/examples/data-visualization/FilterChipGroup/FilterChipGroup.tsx +125 -0
- package/dist/examples/data-visualization/MoreFiltersPopover/MoreFiltersPopover.tsx +132 -0
- package/dist/examples/data-visualization/constants.ts +587 -0
- package/dist/examples/data-visualization/hooks/use-column-config.ts +76 -0
- package/dist/examples/data-visualization/hooks/use-data-explorer-state.ts +313 -0
- package/dist/examples/data-visualization/index.ts +1 -0
- package/dist/examples/data-visualization/types.ts +99 -0
- package/dist/examples/quickaccess/QuickAccess/QuickAccess.tsx +97 -0
- package/dist/examples/quickaccess/index.ts +2 -0
- package/dist/examples/quickaccess/types.ts +11 -0
- package/dist/fastnd-components.js +5708 -5590
- package/package.json +1 -1
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import React, { useState, useRef, useCallback } from 'react'
|
|
2
|
+
import { ChevronUp, ChevronDown, ArrowLeftRight, Sparkles, Plus } from 'lucide-react'
|
|
3
|
+
import {
|
|
4
|
+
Table,
|
|
5
|
+
TableBody,
|
|
6
|
+
TableCell,
|
|
7
|
+
TableHead,
|
|
8
|
+
TableHeader,
|
|
9
|
+
TableRow,
|
|
10
|
+
} from '@/components/ui/table'
|
|
11
|
+
import {
|
|
12
|
+
HoverCard,
|
|
13
|
+
HoverCardContent,
|
|
14
|
+
HoverCardTrigger,
|
|
15
|
+
} from '@/components/ui/hover-card'
|
|
16
|
+
import { Button } from '@/components/ui/button'
|
|
17
|
+
import { Badge } from '@/components/ui/badge'
|
|
18
|
+
import { ScoreBar } from '@/components/ScoreBar/ScoreBar'
|
|
19
|
+
import { renderCell } from '../CellRenderers/CellRenderers'
|
|
20
|
+
import { cn } from '@/lib/utils'
|
|
21
|
+
import type { ColumnDef, ExpandColumnDef, SortState } from '../types'
|
|
22
|
+
|
|
23
|
+
// Icon mapping for expand column headers
|
|
24
|
+
const HEADER_ICONS: Record<string, React.ElementType> = {
|
|
25
|
+
arrowLeftRight: ArrowLeftRight,
|
|
26
|
+
sparkles: Sparkles,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Default minimum widths per column type (px)
|
|
30
|
+
const DEFAULT_MIN_WIDTHS: Partial<Record<string, number>> = {
|
|
31
|
+
favorite: 48,
|
|
32
|
+
expand: 64,
|
|
33
|
+
'status-badge': 100,
|
|
34
|
+
inventory: 100,
|
|
35
|
+
currency: 100,
|
|
36
|
+
'double-text': 200,
|
|
37
|
+
link: 140,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Column types that should wrap text (line-clamp handles truncation)
|
|
41
|
+
const WRAPPABLE_TYPES = new Set(['text', 'link', 'double-text'])
|
|
42
|
+
|
|
43
|
+
function computeRowHeight(visibleColumns: string[], columns: Record<string, ColumnDef>): string {
|
|
44
|
+
for (const key of visibleColumns) {
|
|
45
|
+
const col = columns[key]
|
|
46
|
+
if (!col) continue
|
|
47
|
+
if (WRAPPABLE_TYPES.has(col.type)) return 'min-h-[56px]'
|
|
48
|
+
}
|
|
49
|
+
return ''
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ExpansionCellProps {
|
|
53
|
+
mainColKey: string
|
|
54
|
+
expandColKey: string
|
|
55
|
+
mainCol: ColumnDef
|
|
56
|
+
mapLookup: Record<string, ExpandColumnDef>
|
|
57
|
+
item: Record<string, unknown>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ExpansionCell({ mainColKey, expandColKey, mainCol, mapLookup, item }: ExpansionCellProps) {
|
|
61
|
+
// Expand column itself → show "Hinzufügen" button
|
|
62
|
+
if (mainColKey === expandColKey) {
|
|
63
|
+
return (
|
|
64
|
+
<Button
|
|
65
|
+
variant="ghost"
|
|
66
|
+
size="icon"
|
|
67
|
+
className="size-7"
|
|
68
|
+
aria-label="Hinzufügen"
|
|
69
|
+
onClick={(e) => e.preventDefault()}
|
|
70
|
+
>
|
|
71
|
+
<Plus className="size-3.5" aria-hidden="true" />
|
|
72
|
+
</Button>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ec = mapLookup[mainColKey]
|
|
77
|
+
if (!ec) return null
|
|
78
|
+
|
|
79
|
+
const val = item[ec.key]
|
|
80
|
+
|
|
81
|
+
// double-text with secondary key
|
|
82
|
+
if (mainCol.type === 'double-text' && ec.secondaryKey) {
|
|
83
|
+
const sec = item[ec.secondaryKey]
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
<span className={cn('font-semibold text-[13px] line-clamp-2', ec.bold && 'font-bold')}>
|
|
87
|
+
{val != null ? String(val) : ''}
|
|
88
|
+
</span>
|
|
89
|
+
<span className="text-muted-foreground text-xs line-clamp-1">
|
|
90
|
+
{sec != null ? String(sec) : ''}
|
|
91
|
+
</span>
|
|
92
|
+
</>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// link
|
|
97
|
+
if (mainCol.type === 'link') {
|
|
98
|
+
return (
|
|
99
|
+
<a
|
|
100
|
+
href="#"
|
|
101
|
+
className="text-primary hover:underline text-[13px]"
|
|
102
|
+
onClick={(e) => e.preventDefault()}
|
|
103
|
+
>
|
|
104
|
+
{val != null ? String(val) : ''}
|
|
105
|
+
</a>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// status-badge
|
|
110
|
+
if (mainCol.type === 'status-badge' && mainCol.statusMap && val != null) {
|
|
111
|
+
const status = mainCol.statusMap[String(val)] ?? 'active'
|
|
112
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
113
|
+
active: 'bg-[var(--lifecycle-active-bg)] text-[var(--lifecycle-active)] border-transparent',
|
|
114
|
+
nrnd: 'bg-[var(--lifecycle-nrnd-bg)] text-[var(--lifecycle-nrnd)] border-transparent',
|
|
115
|
+
eol: 'bg-[var(--lifecycle-eol-bg)] text-[var(--lifecycle-eol)] border-transparent',
|
|
116
|
+
production: 'bg-[var(--lifecycle-production-bg)] text-[var(--lifecycle-production)] border-transparent',
|
|
117
|
+
}
|
|
118
|
+
return (
|
|
119
|
+
<Badge className={cn('gap-1.5', STATUS_COLORS[status])}>
|
|
120
|
+
<span
|
|
121
|
+
className={cn('size-1.5 rounded-full', `bg-[var(--lifecycle-${status})]`)}
|
|
122
|
+
aria-hidden="true"
|
|
123
|
+
/>
|
|
124
|
+
{String(val)}
|
|
125
|
+
</Badge>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// score-bar (expand column own type)
|
|
130
|
+
if (ec.type === 'score-bar') {
|
|
131
|
+
return <ScoreBar value={val != null ? Number(val) : 0} />
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// muted text
|
|
135
|
+
if (ec.muted) {
|
|
136
|
+
return <span className="text-muted-foreground text-xs line-clamp-3">{val != null ? String(val) : ''}</span>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// boolean
|
|
140
|
+
if (typeof val === 'boolean') {
|
|
141
|
+
return <span className="text-[13px]">{val ? 'Ja' : 'Nein'}</span>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// default text (ec.bold = semibold, always 3-line clamp per spec)
|
|
145
|
+
return (
|
|
146
|
+
<span className={cn('text-[13px] line-clamp-3', ec.bold && 'font-semibold')}>
|
|
147
|
+
{val != null ? String(val) : ''}
|
|
148
|
+
</span>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface ExpansionSectionProps {
|
|
153
|
+
row: Record<string, unknown>
|
|
154
|
+
colKey: string
|
|
155
|
+
visibleColumns: string[]
|
|
156
|
+
columns: Record<string, ColumnDef>
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function ExpansionSection({ row, colKey, visibleColumns, columns }: ExpansionSectionProps) {
|
|
160
|
+
const col = columns[colKey]
|
|
161
|
+
const items = row[colKey] as Record<string, unknown>[] | undefined
|
|
162
|
+
if (!items?.length || !col.expandColumns) return null
|
|
163
|
+
|
|
164
|
+
const mapLookup: Record<string, ExpandColumnDef> = {}
|
|
165
|
+
for (const ec of col.expandColumns) {
|
|
166
|
+
if (ec.mapTo) mapLookup[ec.mapTo] = ec
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const title = col.expandTitleFn ? col.expandTitleFn(row) : col.expandLabel
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<>
|
|
173
|
+
{/* Title row */}
|
|
174
|
+
<TableRow className="bg-secondary hover:bg-secondary">
|
|
175
|
+
<TableCell
|
|
176
|
+
colSpan={visibleColumns.length}
|
|
177
|
+
className="py-2 text-xs uppercase tracking-wide font-semibold text-muted-foreground"
|
|
178
|
+
>
|
|
179
|
+
{title}
|
|
180
|
+
</TableCell>
|
|
181
|
+
</TableRow>
|
|
182
|
+
|
|
183
|
+
{/* Data rows */}
|
|
184
|
+
{items.map((item, i) => (
|
|
185
|
+
<TableRow
|
|
186
|
+
key={i}
|
|
187
|
+
className={cn(
|
|
188
|
+
'bg-accent/50 hover:bg-primary/5',
|
|
189
|
+
i === items.length - 1 && 'border-b-2 border-b-border',
|
|
190
|
+
)}
|
|
191
|
+
>
|
|
192
|
+
{visibleColumns.map((mainKey) => {
|
|
193
|
+
const mainCol = columns[mainKey]
|
|
194
|
+
if (!mainCol) return <TableCell key={mainKey} />
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<TableCell
|
|
198
|
+
key={mainKey}
|
|
199
|
+
className={cn(
|
|
200
|
+
mainCol.hideTablet && 'hidden lg:table-cell',
|
|
201
|
+
mainCol.hideMobile && 'hidden sm:table-cell',
|
|
202
|
+
)}
|
|
203
|
+
>
|
|
204
|
+
<ExpansionCell
|
|
205
|
+
mainColKey={mainKey}
|
|
206
|
+
expandColKey={colKey}
|
|
207
|
+
mainCol={mainCol}
|
|
208
|
+
mapLookup={mapLookup}
|
|
209
|
+
item={item}
|
|
210
|
+
/>
|
|
211
|
+
</TableCell>
|
|
212
|
+
)
|
|
213
|
+
})}
|
|
214
|
+
</TableRow>
|
|
215
|
+
))}
|
|
216
|
+
</>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface DataTableViewProps {
|
|
221
|
+
data: Record<string, unknown>[]
|
|
222
|
+
columns: Record<string, ColumnDef>
|
|
223
|
+
visibleColumns: string[]
|
|
224
|
+
sort: SortState
|
|
225
|
+
onToggleSort: (column: string) => void
|
|
226
|
+
expandedRows: Set<string>
|
|
227
|
+
onToggleExpansion: (rowId: string, field: string) => void
|
|
228
|
+
favorites: Set<string>
|
|
229
|
+
onToggleFavorite: (id: string) => void
|
|
230
|
+
className?: string
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function DataTableView({
|
|
234
|
+
data,
|
|
235
|
+
columns,
|
|
236
|
+
visibleColumns,
|
|
237
|
+
sort,
|
|
238
|
+
onToggleSort,
|
|
239
|
+
expandedRows,
|
|
240
|
+
onToggleExpansion,
|
|
241
|
+
favorites,
|
|
242
|
+
onToggleFavorite,
|
|
243
|
+
className,
|
|
244
|
+
}: DataTableViewProps) {
|
|
245
|
+
// Column widths: keyed by column key, value in px
|
|
246
|
+
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({})
|
|
247
|
+
|
|
248
|
+
// Resize tracking ref (not state — no re-render during drag)
|
|
249
|
+
const resizeRef = useRef<{
|
|
250
|
+
colKey: string
|
|
251
|
+
startX: number
|
|
252
|
+
startWidth: number
|
|
253
|
+
} | null>(null)
|
|
254
|
+
|
|
255
|
+
const handleResizeMouseDown = useCallback(
|
|
256
|
+
(e: React.MouseEvent, colKey: string) => {
|
|
257
|
+
e.preventDefault()
|
|
258
|
+
const th = (e.target as HTMLElement).closest('th')
|
|
259
|
+
if (!th) return
|
|
260
|
+
// Use actual rendered width so resize starts from wherever the browser laid things out
|
|
261
|
+
const startWidth = columnWidths[colKey] ?? th.getBoundingClientRect().width
|
|
262
|
+
resizeRef.current = { colKey, startX: e.clientX, startWidth }
|
|
263
|
+
|
|
264
|
+
const onMouseMove = (ev: MouseEvent) => {
|
|
265
|
+
if (!resizeRef.current) return
|
|
266
|
+
const delta = ev.clientX - resizeRef.current.startX
|
|
267
|
+
const newWidth = Math.max(60, resizeRef.current.startWidth + delta)
|
|
268
|
+
setColumnWidths((prev) => ({ ...prev, [resizeRef.current!.colKey]: newWidth }))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const onMouseUp = () => {
|
|
272
|
+
resizeRef.current = null
|
|
273
|
+
window.removeEventListener('mousemove', onMouseMove)
|
|
274
|
+
window.removeEventListener('mouseup', onMouseUp)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
window.addEventListener('mousemove', onMouseMove)
|
|
278
|
+
window.addEventListener('mouseup', onMouseUp)
|
|
279
|
+
},
|
|
280
|
+
[columnWidths, columns],
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
const rowHeightClass = computeRowHeight(visibleColumns, columns)
|
|
284
|
+
|
|
285
|
+
// Collect expand column keys for the current domain
|
|
286
|
+
const expandColKeys = visibleColumns.filter((k) => columns[k]?.type === 'expand')
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<div
|
|
290
|
+
className={cn('overflow-x-auto', className)}
|
|
291
|
+
role="region"
|
|
292
|
+
aria-label="Datentabelle"
|
|
293
|
+
tabIndex={0}
|
|
294
|
+
>
|
|
295
|
+
<Table>
|
|
296
|
+
<TableHeader>
|
|
297
|
+
<TableRow className="bg-secondary hover:bg-secondary">
|
|
298
|
+
{visibleColumns.map((colKey) => {
|
|
299
|
+
const col = columns[colKey]
|
|
300
|
+
if (!col) return null
|
|
301
|
+
|
|
302
|
+
const isActiveSort = sort.column === colKey
|
|
303
|
+
// Only apply explicit width when user has dragged; otherwise let browser auto-size
|
|
304
|
+
const userWidth = columnWidths[colKey]
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<TableHead
|
|
308
|
+
key={colKey}
|
|
309
|
+
scope="col"
|
|
310
|
+
className={cn(
|
|
311
|
+
'group/th relative select-none',
|
|
312
|
+
col.hideTablet && 'hidden lg:table-cell',
|
|
313
|
+
col.hideMobile && 'hidden sm:table-cell',
|
|
314
|
+
)}
|
|
315
|
+
style={userWidth != null ? { width: userWidth } : undefined}
|
|
316
|
+
aria-sort={
|
|
317
|
+
isActiveSort
|
|
318
|
+
? sort.direction === 'asc'
|
|
319
|
+
? 'ascending'
|
|
320
|
+
: 'descending'
|
|
321
|
+
: undefined
|
|
322
|
+
}
|
|
323
|
+
>
|
|
324
|
+
{col.sortable ? (
|
|
325
|
+
// Sortable headers render as ghost Button for proper hover/focus states
|
|
326
|
+
<Button
|
|
327
|
+
variant="ghost"
|
|
328
|
+
size="sm"
|
|
329
|
+
className="-mx-2 gap-1 px-2 font-medium text-xs uppercase tracking-[0.03em] text-foreground"
|
|
330
|
+
onClick={() => onToggleSort(colKey)}
|
|
331
|
+
>
|
|
332
|
+
{col.label}
|
|
333
|
+
<span aria-hidden="true" className="text-muted-foreground">
|
|
334
|
+
{isActiveSort ? (
|
|
335
|
+
sort.direction === 'asc' ? (
|
|
336
|
+
<ChevronUp className="size-3.5" />
|
|
337
|
+
) : (
|
|
338
|
+
<ChevronDown className="size-3.5" />
|
|
339
|
+
)
|
|
340
|
+
) : (
|
|
341
|
+
<ChevronDown className="size-3.5 opacity-40" />
|
|
342
|
+
)}
|
|
343
|
+
</span>
|
|
344
|
+
</Button>
|
|
345
|
+
) : col.headerIcon ? (
|
|
346
|
+
// Icon-only header with hover card for expand columns
|
|
347
|
+
<HoverCard openDelay={200}>
|
|
348
|
+
<HoverCardTrigger asChild>
|
|
349
|
+
<span
|
|
350
|
+
className="inline-flex items-center justify-center cursor-help text-muted-foreground"
|
|
351
|
+
aria-label={col.headerTooltip ?? col.label}
|
|
352
|
+
>
|
|
353
|
+
{React.createElement(HEADER_ICONS[col.headerIcon] ?? ArrowLeftRight, {
|
|
354
|
+
className: 'size-4',
|
|
355
|
+
'aria-hidden': true,
|
|
356
|
+
})}
|
|
357
|
+
</span>
|
|
358
|
+
</HoverCardTrigger>
|
|
359
|
+
<HoverCardContent side="top" className="w-auto p-2 text-sm">
|
|
360
|
+
{col.headerTooltip ?? col.label}
|
|
361
|
+
</HoverCardContent>
|
|
362
|
+
</HoverCard>
|
|
363
|
+
) : (
|
|
364
|
+
<span className="text-xs font-medium uppercase tracking-[0.03em]">
|
|
365
|
+
{col.label}
|
|
366
|
+
</span>
|
|
367
|
+
)}
|
|
368
|
+
|
|
369
|
+
{/* Resize handle */}
|
|
370
|
+
<div
|
|
371
|
+
role="separator"
|
|
372
|
+
aria-orientation="vertical"
|
|
373
|
+
className={cn(
|
|
374
|
+
'absolute right-0 top-0 h-full w-1 cursor-col-resize opacity-0 group-hover/th:opacity-100 transition-opacity',
|
|
375
|
+
'hover:bg-primary active:bg-primary active:opacity-100',
|
|
376
|
+
)}
|
|
377
|
+
onMouseDown={(e) => handleResizeMouseDown(e, colKey)}
|
|
378
|
+
onClick={(e) => e.stopPropagation()}
|
|
379
|
+
/>
|
|
380
|
+
</TableHead>
|
|
381
|
+
)
|
|
382
|
+
})}
|
|
383
|
+
</TableRow>
|
|
384
|
+
</TableHeader>
|
|
385
|
+
|
|
386
|
+
<TableBody>
|
|
387
|
+
{data.map((row, idx) => {
|
|
388
|
+
const rowId = String(row.id ?? idx)
|
|
389
|
+
const expandedColKeys = expandColKeys.filter((k) =>
|
|
390
|
+
expandedRows.has(`${rowId}::${k}`),
|
|
391
|
+
)
|
|
392
|
+
const isFavorite = favorites.has(rowId)
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
<React.Fragment key={rowId}>
|
|
396
|
+
<TableRow
|
|
397
|
+
className={cn(
|
|
398
|
+
'animate-in fade-in slide-in-from-bottom-1',
|
|
399
|
+
rowHeightClass,
|
|
400
|
+
expandedColKeys.length > 0 && 'border-b-0',
|
|
401
|
+
)}
|
|
402
|
+
style={{ animationDelay: `${idx * 30}ms` }}
|
|
403
|
+
>
|
|
404
|
+
{visibleColumns.map((colKey) => {
|
|
405
|
+
const col = columns[colKey]
|
|
406
|
+
if (!col) return null
|
|
407
|
+
|
|
408
|
+
const isExpanded = expandedRows.has(`${rowId}::${colKey}`)
|
|
409
|
+
|
|
410
|
+
return (
|
|
411
|
+
<TableCell
|
|
412
|
+
key={colKey}
|
|
413
|
+
className={cn(
|
|
414
|
+
col.hideTablet && 'hidden lg:table-cell',
|
|
415
|
+
col.hideMobile && 'hidden sm:table-cell',
|
|
416
|
+
// Allow wrappable columns to shrink; line-clamp handles truncation
|
|
417
|
+
WRAPPABLE_TYPES.has(col.type) && 'whitespace-normal',
|
|
418
|
+
)}
|
|
419
|
+
style={columnWidths[colKey] != null ? { width: columnWidths[colKey] } : undefined}
|
|
420
|
+
>
|
|
421
|
+
{renderCell(colKey, col, row, {
|
|
422
|
+
isExpanded,
|
|
423
|
+
isFavorite,
|
|
424
|
+
onToggleExpand: () => onToggleExpansion(rowId, colKey),
|
|
425
|
+
onToggleFavorite: () => onToggleFavorite(rowId),
|
|
426
|
+
})}
|
|
427
|
+
</TableCell>
|
|
428
|
+
)
|
|
429
|
+
})}
|
|
430
|
+
</TableRow>
|
|
431
|
+
|
|
432
|
+
{/* Expansion sections for each expanded column */}
|
|
433
|
+
{expandedColKeys.map((expColKey) => (
|
|
434
|
+
<ExpansionSection
|
|
435
|
+
key={`${rowId}::${expColKey}`}
|
|
436
|
+
row={row}
|
|
437
|
+
colKey={expColKey}
|
|
438
|
+
visibleColumns={visibleColumns}
|
|
439
|
+
columns={columns}
|
|
440
|
+
/>
|
|
441
|
+
))}
|
|
442
|
+
</React.Fragment>
|
|
443
|
+
)
|
|
444
|
+
})}
|
|
445
|
+
</TableBody>
|
|
446
|
+
</Table>
|
|
447
|
+
</div>
|
|
448
|
+
)
|
|
449
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Search } from 'lucide-react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
4
|
+
import { useDataExplorerState } from '../hooks/use-data-explorer-state'
|
|
5
|
+
import { useColumnConfig } from '../hooks/use-column-config'
|
|
6
|
+
import { DOMAIN_KEYS, DATA_SOURCES } from '../constants'
|
|
7
|
+
import type { DomainKey } from '../types'
|
|
8
|
+
import { DataExplorerToolbar } from '../DataExplorerToolbar/DataExplorerToolbar'
|
|
9
|
+
import { FilterChipGroup } from '../FilterChipGroup/FilterChipGroup'
|
|
10
|
+
import { MoreFiltersPopover } from '../MoreFiltersPopover/MoreFiltersPopover'
|
|
11
|
+
import { ColumnConfigPopover } from '../ColumnConfigPopover/ColumnConfigPopover'
|
|
12
|
+
import { DataTableView } from '../DataTableView/DataTableView'
|
|
13
|
+
import { DataListView } from '../DataListView/DataListView'
|
|
14
|
+
import { DataCardView } from '../DataCardView/DataCardView'
|
|
15
|
+
import { DataExplorerPagination } from '../DataExplorerPagination/DataExplorerPagination'
|
|
16
|
+
|
|
17
|
+
export function DataVisualizationPage({ className }: { className?: string }) {
|
|
18
|
+
const state = useDataExplorerState()
|
|
19
|
+
const columnConfig = useColumnConfig({ activeDomain: state.activeDomain })
|
|
20
|
+
|
|
21
|
+
const visibleColumns = columnConfig.visibleColumns
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div data-slot="data-visualization-page" className={cn('flex flex-col border border-border rounded-lg overflow-hidden bg-card', className)}>
|
|
25
|
+
<Tabs
|
|
26
|
+
value={state.activeDomain}
|
|
27
|
+
onValueChange={(v) => state.setActiveDomain(v as DomainKey)}
|
|
28
|
+
>
|
|
29
|
+
<TabsList variant="line" className="w-full justify-start border-b border-border bg-card px-6">
|
|
30
|
+
{DOMAIN_KEYS.map((dk) => (
|
|
31
|
+
<TabsTrigger key={dk} value={dk}>
|
|
32
|
+
{DATA_SOURCES[dk].label}
|
|
33
|
+
</TabsTrigger>
|
|
34
|
+
))}
|
|
35
|
+
</TabsList>
|
|
36
|
+
</Tabs>
|
|
37
|
+
|
|
38
|
+
<DataExplorerToolbar
|
|
39
|
+
viewMode={state.viewMode}
|
|
40
|
+
onViewModeChange={state.setViewMode}
|
|
41
|
+
searchTerm={state.searchTerm}
|
|
42
|
+
onSearchChange={state.setSearchTerm}
|
|
43
|
+
hasActiveFilters={state.hasActiveFilters}
|
|
44
|
+
onResetAll={state.clearAllFilters}
|
|
45
|
+
filterSlot={
|
|
46
|
+
<>
|
|
47
|
+
<FilterChipGroup
|
|
48
|
+
columns={state.domainConfig.columns}
|
|
49
|
+
filters={state.filters}
|
|
50
|
+
onToggleFilter={state.toggleFilter}
|
|
51
|
+
onClearFilter={state.clearFilter}
|
|
52
|
+
getFilterOptions={state.getFilterOptions}
|
|
53
|
+
/>
|
|
54
|
+
<MoreFiltersPopover
|
|
55
|
+
columns={state.domainConfig.columns}
|
|
56
|
+
filters={state.filters}
|
|
57
|
+
onToggleFilter={state.toggleFilter}
|
|
58
|
+
onClearSecondaryFilters={state.clearSecondaryFilters}
|
|
59
|
+
getFilterOptions={state.getFilterOptions}
|
|
60
|
+
/>
|
|
61
|
+
</>
|
|
62
|
+
}
|
|
63
|
+
configSlot={
|
|
64
|
+
<ColumnConfigPopover
|
|
65
|
+
columns={state.domainConfig.columns}
|
|
66
|
+
columnOrder={columnConfig.columnOrder}
|
|
67
|
+
columnVisibility={columnConfig.columnVisibility}
|
|
68
|
+
onReorderColumn={columnConfig.reorderColumn}
|
|
69
|
+
onToggleVisibility={columnConfig.toggleColumnVisibility}
|
|
70
|
+
/>
|
|
71
|
+
}
|
|
72
|
+
/>
|
|
73
|
+
|
|
74
|
+
{state.totalFiltered === 0 ? (
|
|
75
|
+
<div
|
|
76
|
+
data-slot="empty-state"
|
|
77
|
+
className="flex flex-col items-center justify-center px-6 py-16 text-center"
|
|
78
|
+
>
|
|
79
|
+
<Search className="size-12 text-muted-foreground/50 mb-4" />
|
|
80
|
+
<h2 className="text-base font-semibold text-foreground mb-1">
|
|
81
|
+
Keine Ergebnisse
|
|
82
|
+
</h2>
|
|
83
|
+
<p className="text-[13px] text-muted-foreground max-w-[360px]">
|
|
84
|
+
Es wurden keine Einträge gefunden, die den aktuellen Filtern entsprechen.
|
|
85
|
+
</p>
|
|
86
|
+
</div>
|
|
87
|
+
) : (
|
|
88
|
+
<>
|
|
89
|
+
{state.viewMode === 'table' && (
|
|
90
|
+
<DataTableView
|
|
91
|
+
data={state.paginatedData}
|
|
92
|
+
columns={state.domainConfig.columns}
|
|
93
|
+
visibleColumns={visibleColumns}
|
|
94
|
+
sort={state.sort}
|
|
95
|
+
onToggleSort={state.toggleSort}
|
|
96
|
+
expandedRows={state.expandedRows}
|
|
97
|
+
onToggleExpansion={state.toggleExpansion}
|
|
98
|
+
favorites={state.favorites}
|
|
99
|
+
onToggleFavorite={state.toggleFavorite}
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
{state.viewMode === 'list' && (
|
|
103
|
+
<DataListView
|
|
104
|
+
data={state.paginatedData}
|
|
105
|
+
columns={state.domainConfig.columns}
|
|
106
|
+
layout={state.domainConfig.layout.list}
|
|
107
|
+
expandedRows={state.expandedRows}
|
|
108
|
+
onToggleExpansion={state.toggleExpansion}
|
|
109
|
+
favorites={state.favorites}
|
|
110
|
+
onToggleFavorite={state.toggleFavorite}
|
|
111
|
+
/>
|
|
112
|
+
)}
|
|
113
|
+
{state.viewMode === 'card' && (
|
|
114
|
+
<DataCardView
|
|
115
|
+
data={state.paginatedData}
|
|
116
|
+
columns={state.domainConfig.columns}
|
|
117
|
+
layout={state.domainConfig.layout.card}
|
|
118
|
+
expandedRows={state.expandedRows}
|
|
119
|
+
onToggleExpansion={state.toggleExpansion}
|
|
120
|
+
favorites={state.favorites}
|
|
121
|
+
onToggleFavorite={state.toggleFavorite}
|
|
122
|
+
/>
|
|
123
|
+
)}
|
|
124
|
+
</>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{state.totalFiltered > 0 && (
|
|
128
|
+
<DataExplorerPagination
|
|
129
|
+
currentPage={state.currentPage}
|
|
130
|
+
totalPages={state.totalPages}
|
|
131
|
+
pageSize={state.pageSize}
|
|
132
|
+
totalFiltered={state.totalFiltered}
|
|
133
|
+
resultLabel={state.domainConfig.resultLabel}
|
|
134
|
+
onPageChange={state.setPage}
|
|
135
|
+
onPageSizeChange={state.setPageSize}
|
|
136
|
+
/>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|