@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,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 }
|