@fastnd/components 1.0.30 → 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.
Files changed (57) 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/StatusDonutChart/StatusDonutChart.tsx +41 -56
  5. package/dist/examples/data-visualization/CardCarouselPanel/CardCarouselPanel.tsx +171 -0
  6. package/dist/examples/data-visualization/CellRenderers/CellRenderers.tsx +175 -0
  7. package/dist/examples/data-visualization/ColumnConfigPopover/ColumnConfigPopover.tsx +124 -0
  8. package/dist/examples/data-visualization/DataCardView/DataCardView.tsx +223 -0
  9. package/dist/examples/data-visualization/DataExplorerPagination/DataExplorerPagination.tsx +143 -0
  10. package/dist/examples/data-visualization/DataExplorerToolbar/DataExplorerToolbar.tsx +88 -0
  11. package/dist/examples/data-visualization/DataListView/DataListView.tsx +246 -0
  12. package/dist/examples/data-visualization/DataTableView/DataTableView.tsx +449 -0
  13. package/dist/examples/data-visualization/DataVisualizationPage/DataVisualizationPage.tsx +140 -0
  14. package/dist/examples/data-visualization/FilterChipGroup/FilterChipGroup.tsx +125 -0
  15. package/dist/examples/data-visualization/MoreFiltersPopover/MoreFiltersPopover.tsx +132 -0
  16. package/dist/examples/data-visualization/constants.ts +587 -0
  17. package/dist/examples/data-visualization/hooks/use-column-config.ts +76 -0
  18. package/dist/examples/data-visualization/hooks/use-data-explorer-state.ts +313 -0
  19. package/dist/examples/data-visualization/index.ts +1 -0
  20. package/dist/examples/data-visualization/types.ts +99 -0
  21. package/dist/examples/quickaccess/QuickAccess/QuickAccess.tsx +97 -0
  22. package/dist/examples/quickaccess/index.ts +2 -0
  23. package/dist/examples/quickaccess/types.ts +11 -0
  24. package/dist/fastnd-components.js +5708 -5590
  25. package/package.json +1 -1
  26. package/dist/examples/data-explorer/CardCarouselPanel/CardCarouselPanel.tsx +0 -197
  27. package/dist/examples/data-explorer/CardView/CardView.tsx +0 -168
  28. package/dist/examples/data-explorer/ColumnConfigPopover/ColumnConfigPopover.tsx +0 -157
  29. package/dist/examples/data-explorer/DataExplorerEmpty/DataExplorerEmpty.tsx +0 -56
  30. package/dist/examples/data-explorer/DataExplorerPage/DataExplorerPage.tsx +0 -101
  31. package/dist/examples/data-explorer/DataExplorerPagination/DataExplorerPagination.tsx +0 -129
  32. package/dist/examples/data-explorer/DataExplorerToolbar/DataExplorerToolbar.tsx +0 -143
  33. package/dist/examples/data-explorer/DomainSwitcher/DomainSwitcher.tsx +0 -36
  34. package/dist/examples/data-explorer/ExpansionRows/ExpansionRows.tsx +0 -180
  35. package/dist/examples/data-explorer/FilterChip/FilterChip.tsx +0 -85
  36. package/dist/examples/data-explorer/FilterPopoverContent/FilterPopoverContent.tsx +0 -73
  37. package/dist/examples/data-explorer/ListView/ListView.tsx +0 -305
  38. package/dist/examples/data-explorer/MoreFiltersPopover/MoreFiltersPopover.tsx +0 -113
  39. package/dist/examples/data-explorer/TableView/TableView.tsx +0 -193
  40. package/dist/examples/data-explorer/cells/CellRenderer.tsx +0 -147
  41. package/dist/examples/data-explorer/cells/CurrencyCell.tsx +0 -31
  42. package/dist/examples/data-explorer/cells/DoubleTextCell.tsx +0 -27
  43. package/dist/examples/data-explorer/cells/ExpandButton.tsx +0 -67
  44. package/dist/examples/data-explorer/cells/InventoryBadgeCell.tsx +0 -52
  45. package/dist/examples/data-explorer/cells/LinkCell.tsx +0 -42
  46. package/dist/examples/data-explorer/cells/ScoreBar.tsx +0 -50
  47. package/dist/examples/data-explorer/cells/StatusBadgeCell.tsx +0 -39
  48. package/dist/examples/data-explorer/cells/TextCell.tsx +0 -35
  49. package/dist/examples/data-explorer/cells/index.ts +0 -26
  50. package/dist/examples/data-explorer/domains/applications.ts +0 -225
  51. package/dist/examples/data-explorer/domains/customers.ts +0 -267
  52. package/dist/examples/data-explorer/domains/index.ts +0 -26
  53. package/dist/examples/data-explorer/domains/products.ts +0 -1116
  54. package/dist/examples/data-explorer/domains/projects.ts +0 -205
  55. package/dist/examples/data-explorer/hooks/use-data-explorer-state.ts +0 -371
  56. package/dist/examples/data-explorer/index.ts +0 -3
  57. package/dist/examples/data-explorer/types.ts +0 -239
@@ -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-3', 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 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-2 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-center">
167
+ <span className="text-xs text-muted-foreground">{r.label}</span>
168
+ <span className="text-[13px] font-medium">{valueNode}</span>
169
+ </div>
170
+ )
171
+ })}
172
+ </div>
173
+ )}
174
+
175
+ {/* Footer — expand buttons */}
176
+ {expandFields.length > 0 && (
177
+ <div className="p-4 pt-3">
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
+ }
@@ -0,0 +1,88 @@
1
+ import React from 'react'
2
+ import { LayoutGrid, List, RotateCcw, Search, Table2 } from 'lucide-react'
3
+ import { Button } from '@/components/ui/button'
4
+ import { Input } from '@/components/ui/input'
5
+ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
6
+ import { cn } from '@/lib/utils'
7
+ import type { ViewMode } from '../types'
8
+
9
+ interface DataExplorerToolbarProps {
10
+ viewMode: ViewMode
11
+ onViewModeChange: (mode: ViewMode) => void
12
+ searchTerm: string
13
+ onSearchChange: (term: string) => void
14
+ hasActiveFilters: boolean
15
+ onResetAll: () => void
16
+ filterSlot: React.ReactNode
17
+ configSlot: React.ReactNode
18
+ className?: string
19
+ }
20
+
21
+ export function DataExplorerToolbar({
22
+ viewMode,
23
+ onViewModeChange,
24
+ searchTerm,
25
+ onSearchChange,
26
+ hasActiveFilters,
27
+ onResetAll,
28
+ filterSlot,
29
+ configSlot,
30
+ className,
31
+ }: DataExplorerToolbarProps) {
32
+ return (
33
+ <div
34
+ role="toolbar"
35
+ aria-label="Daten-Toolbar"
36
+ className={cn('flex flex-col gap-3 px-6 py-4 border-b border-border', className)}
37
+ >
38
+ {/* Row 1: view switcher + config */}
39
+ <div className="flex items-center justify-between">
40
+ <Tabs value={viewMode} onValueChange={(v) => onViewModeChange(v as ViewMode)}>
41
+ <TabsList>
42
+ <TabsTrigger value="table">
43
+ <Table2 className="size-4" />
44
+ <span className="max-sm:hidden">Tabelle</span>
45
+ </TabsTrigger>
46
+ <TabsTrigger value="list">
47
+ <List className="size-4" />
48
+ <span className="max-sm:hidden">Liste</span>
49
+ </TabsTrigger>
50
+ <TabsTrigger value="card">
51
+ <LayoutGrid className="size-4" />
52
+ <span className="max-sm:hidden">Karten</span>
53
+ </TabsTrigger>
54
+ </TabsList>
55
+ </Tabs>
56
+ {configSlot}
57
+ </div>
58
+
59
+ {/* Row 2: filter chips + search */}
60
+ <div className="flex items-center justify-between gap-3">
61
+ <div className="flex items-center gap-2 flex-wrap">
62
+ {filterSlot}
63
+ {hasActiveFilters && (
64
+ <Button
65
+ variant="ghost"
66
+ size="sm"
67
+ className="text-destructive"
68
+ onClick={onResetAll}
69
+ >
70
+ <RotateCcw className="size-4" />
71
+ Zurücksetzen
72
+ </Button>
73
+ )}
74
+ </div>
75
+ <div className="relative">
76
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
77
+ <Input
78
+ placeholder="Suchen..."
79
+ value={searchTerm}
80
+ onChange={(e) => onSearchChange(e.target.value)}
81
+ className="pl-9 min-w-[240px] max-sm:min-w-[160px]"
82
+ aria-label="Datensätze durchsuchen"
83
+ />
84
+ </div>
85
+ </div>
86
+ </div>
87
+ )
88
+ }
@@ -0,0 +1,246 @@
1
+ import React from 'react'
2
+ import { Plus } from 'lucide-react'
3
+ import { Button } from '@/components/ui/button'
4
+ import {
5
+ Table,
6
+ TableBody,
7
+ TableCell,
8
+ TableHead,
9
+ TableHeader,
10
+ TableRow,
11
+ } from '@/components/ui/table'
12
+ import { ScoreBar } from '@/components/ScoreBar/ScoreBar'
13
+ import { FavoriteButton } from '@/components/FavoriteButton/FavoriteButton'
14
+ import { renderCell } from '../CellRenderers/CellRenderers'
15
+ import { cn } from '@/lib/utils'
16
+ import type { ColumnDef, ExpandColumnDef, ListLayout } from '../types'
17
+
18
+ export interface DataListViewProps {
19
+ data: Record<string, unknown>[]
20
+ columns: Record<string, ColumnDef>
21
+ layout: ListLayout
22
+ expandedRows: Set<string>
23
+ onToggleExpansion: (rowId: string, field: string) => void
24
+ favorites: Set<string>
25
+ onToggleFavorite: (id: string) => void
26
+ className?: string
27
+ }
28
+
29
+ interface ExpansionTableProps {
30
+ row: Record<string, unknown>
31
+ colKey: string
32
+ columns: Record<string, ColumnDef>
33
+ }
34
+
35
+ function ExpansionTable({ row, colKey, columns }: ExpansionTableProps) {
36
+ const col = columns[colKey]
37
+ if (!col?.expandColumns) return null
38
+
39
+ const items = row[colKey]
40
+ if (!Array.isArray(items) || items.length === 0) return null
41
+
42
+ const title = col.expandTitleFn ? col.expandTitleFn(row) : colKey
43
+
44
+ return (
45
+ <div>
46
+ <div className="text-sm font-semibold mb-2">{title}</div>
47
+ <Table>
48
+ <TableHeader>
49
+ <TableRow>
50
+ {col.expandColumns.map((ec: ExpandColumnDef) => (
51
+ <TableHead key={ec.key} className="text-xs h-8 px-2">
52
+ {ec.label}
53
+ </TableHead>
54
+ ))}
55
+ <TableHead className="w-10" />
56
+ </TableRow>
57
+ </TableHeader>
58
+ <TableBody>
59
+ {(items as Record<string, unknown>[]).map((item, index) => (
60
+ <TableRow key={index}>
61
+ {col.expandColumns!.map((ec: ExpandColumnDef) => {
62
+ const v = item[ec.key]
63
+ return (
64
+ <TableCell key={ec.key} className="px-2 py-1.5 text-xs">
65
+ <ExpansionCell ec={ec} value={v} />
66
+ </TableCell>
67
+ )
68
+ })}
69
+ <TableCell className="px-2 py-1.5">
70
+ <Button
71
+ variant="ghost"
72
+ size="icon-xs"
73
+ title="Hinzufügen"
74
+ aria-label="Hinzufügen"
75
+ onClick={(e) => e.preventDefault()}
76
+ >
77
+ <Plus className="size-3.5" aria-hidden="true" />
78
+ </Button>
79
+ </TableCell>
80
+ </TableRow>
81
+ ))}
82
+ </TableBody>
83
+ </Table>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ interface ExpansionCellProps {
89
+ ec: ExpandColumnDef
90
+ value: unknown
91
+ }
92
+
93
+ function ExpansionCell({ ec, value }: ExpansionCellProps) {
94
+ if (ec.type === 'score-bar') {
95
+ return <ScoreBar value={typeof value === 'number' ? value : 0} />
96
+ }
97
+
98
+ const text = value != null ? String(value) : ''
99
+
100
+ if (ec.muted) {
101
+ const hasAI = text.includes('AI') || text.includes('Ki')
102
+ return (
103
+ <span className="text-muted-foreground text-xs line-clamp-3">
104
+ {text}
105
+ {hasAI && (
106
+ <span className="ml-1 text-[10px] font-medium bg-primary/10 text-primary rounded px-1">
107
+ KI
108
+ </span>
109
+ )}
110
+ </span>
111
+ )
112
+ }
113
+
114
+ if (ec.bold) {
115
+ return <span className="font-semibold text-[13px] line-clamp-3">{text}</span>
116
+ }
117
+
118
+ if (typeof value === 'boolean') {
119
+ return <span>{value ? 'Ja' : 'Nein'}</span>
120
+ }
121
+
122
+ return <span>{text}</span>
123
+ }
124
+
125
+ function DataListView({
126
+ data,
127
+ columns,
128
+ layout,
129
+ expandedRows,
130
+ onToggleExpansion,
131
+ favorites,
132
+ onToggleFavorite,
133
+ className,
134
+ }: DataListViewProps) {
135
+ const expandFields = layout.expandFields ?? (layout.expandField ? [layout.expandField] : [])
136
+ const titleCol = columns[layout.titleField]
137
+ const isDoubleText = titleCol?.type === 'double-text'
138
+
139
+ return (
140
+ <div
141
+ data-slot="data-list-view"
142
+ className={cn(
143
+ 'border border-border rounded-lg overflow-hidden divide-y divide-border',
144
+ className,
145
+ )}
146
+ >
147
+ {data.map((row) => {
148
+ const rowId = String(row.id ?? '')
149
+ const titleValue = row[layout.titleField]
150
+ const subtitleValue = isDoubleText && titleCol.secondary ? row[titleCol.secondary] : undefined
151
+
152
+ return (
153
+ <div
154
+ key={rowId}
155
+ className="grid grid-cols-[36px_1fr_auto_auto] sm:grid-cols-[36px_1fr_auto_auto] items-start gap-x-3 px-4 py-3 hover:bg-accent transition-colors"
156
+ >
157
+ {/* Col 1: Favorite */}
158
+ <div className="flex items-center justify-center pt-0.5">
159
+ <FavoriteButton
160
+ pressed={favorites.has(rowId)}
161
+ itemName={titleValue != null ? String(titleValue) : ''}
162
+ onPressedChange={() => onToggleFavorite(rowId)}
163
+ />
164
+ </div>
165
+
166
+ {/* Col 2: Title block */}
167
+ <div className="min-w-0">
168
+ <div className="font-semibold text-sm truncate max-w-[280px] sm:max-w-none">
169
+ {titleValue != null ? String(titleValue) : ''}
170
+ </div>
171
+ <div className="flex flex-wrap items-center gap-2 mt-0.5">
172
+ {subtitleValue != null && (
173
+ <span className="text-xs text-muted-foreground truncate max-w-[320px] sm:max-w-none">
174
+ {String(subtitleValue)}
175
+ </span>
176
+ )}
177
+ {layout.metaFields.map((field) => {
178
+ const v = row[field]
179
+ if (v == null || v === '') return null
180
+ return (
181
+ <span
182
+ key={field}
183
+ className="shrink-0 bg-secondary rounded-md px-2 py-0.5 text-xs max-w-[180px] truncate"
184
+ >
185
+ {String(v)}
186
+ </span>
187
+ )
188
+ })}
189
+ </div>
190
+ </div>
191
+
192
+ {/* Col 3: Badges */}
193
+ <div className="flex items-center gap-2 pt-0.5 max-sm:col-start-2 max-sm:col-end-3 max-sm:row-start-2">
194
+ {layout.badgeFields.map((field) => {
195
+ const col = columns[field]
196
+ if (!col) return null
197
+ return (
198
+ <React.Fragment key={field}>
199
+ {renderCell(field, col, row, {
200
+ mode: col.type === 'inventory' ? 'inventory-label' : 'default',
201
+ })}
202
+ </React.Fragment>
203
+ )
204
+ })}
205
+ </div>
206
+
207
+ {/* Col 4: Expand buttons */}
208
+ <div className="flex items-center gap-2 pt-0.5 max-sm:col-start-2 max-sm:col-end-3 max-sm:row-start-3">
209
+ {expandFields.map((field) => {
210
+ const col = columns[field]
211
+ if (!col) return null
212
+ const expandKey = `${rowId}::${field}`
213
+ return (
214
+ <React.Fragment key={field}>
215
+ {renderCell(field, col, row, {
216
+ mode: 'compact',
217
+ isExpanded: expandedRows.has(expandKey),
218
+ onToggleExpand: () => onToggleExpansion(rowId, field),
219
+ })}
220
+ </React.Fragment>
221
+ )
222
+ })}
223
+ </div>
224
+
225
+ {/* Expansion content — full width */}
226
+ {expandFields.map((field) => {
227
+ const expandKey = `${rowId}::${field}`
228
+ if (!expandedRows.has(expandKey)) return null
229
+ return (
230
+ <div
231
+ key={field}
232
+ className="col-span-full border-t border-border pt-3 mt-1 max-sm:col-start-1 max-sm:row-start-4"
233
+ data-slot="expansion-content"
234
+ >
235
+ <ExpansionTable row={row} colKey={field} columns={columns} />
236
+ </div>
237
+ )
238
+ })}
239
+ </div>
240
+ )
241
+ })}
242
+ </div>
243
+ )
244
+ }
245
+
246
+ export { DataListView }