@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.
- 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/DashboardPage/DashboardPage.tsx +1 -1
- package/dist/examples/dashboard/ProjectList/ProjectList.tsx +87 -47
- package/dist/examples/dashboard/StatusDonutChart/StatusDonutChart.tsx +41 -56
- package/dist/examples/dashboard/constants.ts +20 -6
- package/dist/examples/dashboard/types.ts +2 -0
- package/dist/examples/data-visualization/CardCarouselPanel/CardCarouselPanel.tsx +171 -0
- package/dist/examples/data-visualization/CellRenderers/CellRenderers.tsx +171 -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 +283 -0
- package/dist/examples/data-visualization/DataTableView/DataTableView.tsx +455 -0
- package/dist/examples/data-visualization/DataVisualizationPage/DataVisualizationPage.tsx +151 -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 +318 -0
- package/dist/examples/data-visualization/index.ts +1 -0
- package/dist/examples/data-visualization/types.ts +110 -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,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
|
+
}
|