@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,12 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
interface QuickAccessCardProps extends React.ComponentProps<'a'> {
|
|
3
|
+
label: string;
|
|
4
|
+
count: number;
|
|
5
|
+
countLabel?: string;
|
|
6
|
+
icon: React.ReactNode;
|
|
7
|
+
variant?: 'default' | 'overdue';
|
|
8
|
+
isActive?: boolean;
|
|
9
|
+
}
|
|
10
|
+
declare const QuickAccessCard: React.ForwardRefExoticComponent<Omit<QuickAccessCardProps, "ref"> & React.RefAttributes<HTMLAnchorElement>>;
|
|
11
|
+
export { QuickAccessCard };
|
|
12
|
+
export type { QuickAccessCardProps };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
interface ScoreBarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
|
|
3
|
+
value: number;
|
|
4
|
+
showLabel?: boolean;
|
|
5
|
+
}
|
|
6
|
+
declare const ScoreBar: React.ForwardRefExoticComponent<ScoreBarProps & React.RefAttributes<HTMLDivElement>>;
|
|
7
|
+
export { ScoreBar };
|
|
8
|
+
export type { ScoreBarProps };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
|
-
import { Pie, PieChart,
|
|
2
|
+
import { Pie, PieChart, Sector } from 'recharts'
|
|
3
3
|
import type { PieSectorShapeProps } from 'recharts/types/polar/Pie'
|
|
4
4
|
import {
|
|
5
5
|
ChartContainer,
|
|
@@ -57,62 +57,47 @@ const StatusDonutChart = React.forwardRef<HTMLDivElement, StatusDonutChartProps>
|
|
|
57
57
|
className={cn(className)}
|
|
58
58
|
{...props}
|
|
59
59
|
>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
content={({ viewBox }) => {
|
|
85
|
-
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
|
86
|
-
return (
|
|
87
|
-
<text
|
|
88
|
-
x={viewBox.cx}
|
|
89
|
-
y={viewBox.cy}
|
|
90
|
-
textAnchor="middle"
|
|
91
|
-
dominantBaseline="middle"
|
|
92
|
-
>
|
|
93
|
-
<tspan
|
|
94
|
-
x={viewBox.cx}
|
|
95
|
-
y={viewBox.cy}
|
|
96
|
-
className="fill-foreground text-3xl font-bold"
|
|
97
|
-
style={{ fontFamily: 'var(--font-clash)' }}
|
|
98
|
-
>
|
|
99
|
-
{total.toLocaleString()}
|
|
100
|
-
</tspan>
|
|
101
|
-
<tspan
|
|
102
|
-
x={viewBox.cx}
|
|
103
|
-
y={(viewBox.cy || 0) + 24}
|
|
104
|
-
className="fill-muted-foreground text-xs uppercase tracking-wider"
|
|
105
|
-
>
|
|
106
|
-
Gesamt
|
|
107
|
-
</tspan>
|
|
108
|
-
</text>
|
|
109
|
-
)
|
|
110
|
-
}
|
|
111
|
-
}}
|
|
60
|
+
{/* Relative wrapper so the center label overlay can be positioned absolutely */}
|
|
61
|
+
<div className="relative mx-auto aspect-square max-h-[200px]">
|
|
62
|
+
<ChartContainer config={chartConfig} className="size-full">
|
|
63
|
+
<PieChart>
|
|
64
|
+
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
|
|
65
|
+
<Pie
|
|
66
|
+
data={chartData}
|
|
67
|
+
dataKey="count"
|
|
68
|
+
nameKey="status"
|
|
69
|
+
innerRadius={60}
|
|
70
|
+
outerRadius={85}
|
|
71
|
+
strokeWidth={5}
|
|
72
|
+
className="cursor-pointer"
|
|
73
|
+
onClick={handlePieClick}
|
|
74
|
+
shape={({
|
|
75
|
+
index,
|
|
76
|
+
outerRadius: or = 0,
|
|
77
|
+
...sectorProps
|
|
78
|
+
}: PieSectorShapeProps) => (
|
|
79
|
+
<Sector
|
|
80
|
+
{...sectorProps}
|
|
81
|
+
outerRadius={index === activeIndex ? or + 10 : or}
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
112
84
|
/>
|
|
113
|
-
</
|
|
114
|
-
</
|
|
115
|
-
|
|
85
|
+
</PieChart>
|
|
86
|
+
</ChartContainer>
|
|
87
|
+
|
|
88
|
+
{/* Center label — rendered as HTML so it is always in the DOM (recharts SVG labels are invisible in jsdom) */}
|
|
89
|
+
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-0.5">
|
|
90
|
+
<span
|
|
91
|
+
className="text-3xl font-bold text-foreground"
|
|
92
|
+
style={{ fontFamily: 'var(--font-clash)' }}
|
|
93
|
+
>
|
|
94
|
+
{total.toLocaleString()}
|
|
95
|
+
</span>
|
|
96
|
+
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
|
97
|
+
Gesamt
|
|
98
|
+
</span>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
116
101
|
</div>
|
|
117
102
|
)
|
|
118
103
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Plus } from 'lucide-react'
|
|
3
|
+
import { Badge } from '@/components/ui/badge'
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import {
|
|
6
|
+
Carousel,
|
|
7
|
+
CarouselContent,
|
|
8
|
+
CarouselItem,
|
|
9
|
+
CarouselPrevious,
|
|
10
|
+
CarouselNext,
|
|
11
|
+
} from '@/components/ui/carousel'
|
|
12
|
+
import { ScoreBar } from '@/components/ScoreBar/ScoreBar'
|
|
13
|
+
import { renderCell } from '../CellRenderers/CellRenderers'
|
|
14
|
+
import { cn } from '@/lib/utils'
|
|
15
|
+
import type { CardLayout, ColumnDef, ExpandColumnDef } from '../types'
|
|
16
|
+
|
|
17
|
+
export interface CardCarouselPanelProps {
|
|
18
|
+
title: string
|
|
19
|
+
items: Record<string, unknown>[]
|
|
20
|
+
expandColumns: ExpandColumnDef[]
|
|
21
|
+
columns: Record<string, ColumnDef>
|
|
22
|
+
layout: CardLayout
|
|
23
|
+
className?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildVirtualRow(
|
|
27
|
+
item: Record<string, unknown>,
|
|
28
|
+
expandColumns: ExpandColumnDef[],
|
|
29
|
+
columns: Record<string, ColumnDef>,
|
|
30
|
+
): Record<string, unknown> {
|
|
31
|
+
const virtualRow: Record<string, unknown> = {}
|
|
32
|
+
for (const ec of expandColumns) {
|
|
33
|
+
if (ec.mapTo) {
|
|
34
|
+
virtualRow[ec.mapTo] = item[ec.key]
|
|
35
|
+
if (ec.secondaryKey) {
|
|
36
|
+
const mainCol = columns[ec.mapTo]
|
|
37
|
+
if (mainCol?.secondary) {
|
|
38
|
+
virtualRow[mainCol.secondary] = item[ec.secondaryKey]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return virtualRow
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function CardCarouselPanel({
|
|
47
|
+
title,
|
|
48
|
+
items,
|
|
49
|
+
expandColumns,
|
|
50
|
+
columns,
|
|
51
|
+
layout,
|
|
52
|
+
className,
|
|
53
|
+
}: CardCarouselPanelProps) {
|
|
54
|
+
const scoreEc = expandColumns.find((ec) => ec.type === 'score-bar')
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
data-slot="card-carousel-panel"
|
|
59
|
+
className={cn('border border-border rounded-lg bg-card', className)}
|
|
60
|
+
>
|
|
61
|
+
<Carousel opts={{ align: 'start' }}>
|
|
62
|
+
<div className="flex items-center justify-between p-3 border-b border-border">
|
|
63
|
+
<div className="flex items-center gap-2">
|
|
64
|
+
<span className="text-sm font-semibold">{title}</span>
|
|
65
|
+
<Badge variant="secondary" className="text-xs">
|
|
66
|
+
{items.length}
|
|
67
|
+
</Badge>
|
|
68
|
+
</div>
|
|
69
|
+
<div className="flex items-center gap-1">
|
|
70
|
+
<CarouselPrevious className="static translate-y-0 size-7 rounded-md" />
|
|
71
|
+
<CarouselNext className="static translate-y-0 size-7 rounded-md" />
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<CarouselContent className="p-3 -ml-3">
|
|
76
|
+
{items.map((item, index) => {
|
|
77
|
+
const virtualRow = buildVirtualRow(item, expandColumns, columns)
|
|
78
|
+
const titleValue = virtualRow[layout.titleField]
|
|
79
|
+
const subtitleValue = layout.subtitleField
|
|
80
|
+
? virtualRow[layout.subtitleField]
|
|
81
|
+
: undefined
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<CarouselItem
|
|
85
|
+
key={index}
|
|
86
|
+
className="basis-1/3 pl-3 max-lg:basis-1/2 max-sm:basis-[85%]"
|
|
87
|
+
>
|
|
88
|
+
<div className="border border-border rounded-lg p-4 flex flex-col gap-3 h-full">
|
|
89
|
+
{/* Header — single-line truncate for carousel mini-card per spec */}
|
|
90
|
+
<div>
|
|
91
|
+
<div className="text-[13px] font-semibold truncate">
|
|
92
|
+
{titleValue != null ? String(titleValue) : ''}
|
|
93
|
+
</div>
|
|
94
|
+
{subtitleValue != null && (
|
|
95
|
+
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
|
|
96
|
+
{String(subtitleValue)}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{/* Badges */}
|
|
102
|
+
{layout.badgeFields.length > 0 && (
|
|
103
|
+
<div className="flex flex-wrap gap-1">
|
|
104
|
+
{layout.badgeFields.map((bf) => {
|
|
105
|
+
const col = columns[bf]
|
|
106
|
+
if (!col || virtualRow[bf] == null) return null
|
|
107
|
+
return (
|
|
108
|
+
<React.Fragment key={bf}>
|
|
109
|
+
{renderCell(bf, col, virtualRow)}
|
|
110
|
+
</React.Fragment>
|
|
111
|
+
)
|
|
112
|
+
})}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{/* Rows */}
|
|
117
|
+
{layout.rows.length > 0 && (
|
|
118
|
+
<div className="flex flex-col gap-1.5 flex-1">
|
|
119
|
+
{layout.rows.map((r) => {
|
|
120
|
+
if (virtualRow[r.field] == null) return null
|
|
121
|
+
const col = columns[r.field]
|
|
122
|
+
const valueNode = col
|
|
123
|
+
? renderCell(
|
|
124
|
+
r.field,
|
|
125
|
+
col,
|
|
126
|
+
virtualRow,
|
|
127
|
+
r.rendererOverride === 'inventory-label'
|
|
128
|
+
? { mode: 'inventory-label' }
|
|
129
|
+
: {},
|
|
130
|
+
)
|
|
131
|
+
: String(virtualRow[r.field] ?? '')
|
|
132
|
+
return (
|
|
133
|
+
<div
|
|
134
|
+
key={r.field}
|
|
135
|
+
className="flex justify-between items-center text-[13px]"
|
|
136
|
+
>
|
|
137
|
+
<span className="text-muted-foreground">{r.label}</span>
|
|
138
|
+
<span className="font-medium">{valueNode}</span>
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
})}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{/* Score bar */}
|
|
146
|
+
{scoreEc && (
|
|
147
|
+
<ScoreBar
|
|
148
|
+
value={
|
|
149
|
+
typeof item[scoreEc.key] === 'number'
|
|
150
|
+
? (item[scoreEc.key] as number)
|
|
151
|
+
: 0
|
|
152
|
+
}
|
|
153
|
+
/>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{/* Footer */}
|
|
157
|
+
<Button variant="outline" size="sm" className="w-full gap-1.5">
|
|
158
|
+
<Plus className="size-3.5" aria-hidden="true" />
|
|
159
|
+
Hinzufügen
|
|
160
|
+
</Button>
|
|
161
|
+
</div>
|
|
162
|
+
</CarouselItem>
|
|
163
|
+
)
|
|
164
|
+
})}
|
|
165
|
+
</CarouselContent>
|
|
166
|
+
</Carousel>
|
|
167
|
+
</div>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export { CardCarouselPanel }
|
|
@@ -0,0 +1,175 @@
|
|
|
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 } from '../types'
|
|
9
|
+
|
|
10
|
+
export interface RenderCellOptions {
|
|
11
|
+
mode?: 'default' | 'compact' | 'inventory-label'
|
|
12
|
+
isExpanded?: boolean
|
|
13
|
+
isFavorite?: boolean
|
|
14
|
+
onToggleExpand?: () => void
|
|
15
|
+
onToggleFavorite?: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
19
|
+
active: 'bg-[var(--lifecycle-active-bg)] text-[var(--lifecycle-active)] border-transparent',
|
|
20
|
+
nrnd: 'bg-[var(--lifecycle-nrnd-bg)] text-[var(--lifecycle-nrnd)] border-transparent',
|
|
21
|
+
eol: 'bg-[var(--lifecycle-eol-bg)] text-[var(--lifecycle-eol)] border-transparent',
|
|
22
|
+
production: 'bg-[var(--lifecycle-production-bg)] text-[var(--lifecycle-production)] border-transparent',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const INVENTORY_COLORS: Record<string, string> = {
|
|
26
|
+
high: 'bg-[var(--inventory-high-bg)] text-[var(--inventory-high)] border-transparent',
|
|
27
|
+
medium: 'bg-[var(--inventory-medium-bg)] text-[var(--inventory-medium)] border-transparent',
|
|
28
|
+
low: 'bg-[var(--inventory-low-bg)] text-[var(--inventory-low)] border-transparent',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const EXPAND_ICONS: Record<string, React.ElementType> = {
|
|
32
|
+
arrowLeftRight: ArrowLeftRight,
|
|
33
|
+
sparkles: Sparkles,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function renderCell(
|
|
37
|
+
colKey: string,
|
|
38
|
+
col: ColumnDef,
|
|
39
|
+
row: Record<string, unknown>,
|
|
40
|
+
options: RenderCellOptions = {},
|
|
41
|
+
): React.ReactNode {
|
|
42
|
+
const { mode = 'default', isExpanded = false, isFavorite = false, onToggleExpand, onToggleFavorite } = options
|
|
43
|
+
const val = row[colKey]
|
|
44
|
+
|
|
45
|
+
switch (col.type) {
|
|
46
|
+
case 'text': {
|
|
47
|
+
// col.rowLines drives the clamp; default 2 so columns can shrink without growing rows
|
|
48
|
+
const clamp = col.rowLines === 3 ? 'line-clamp-3' : 'line-clamp-2'
|
|
49
|
+
return (
|
|
50
|
+
<span className={cn(clamp, 'text-[13px]')}>
|
|
51
|
+
{val != null ? String(val) : ''}
|
|
52
|
+
</span>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
case 'double-text': {
|
|
57
|
+
const secondary = col.secondary != null ? row[col.secondary] : undefined
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
<span className="font-medium text-[13px] line-clamp-2">
|
|
61
|
+
{val != null ? String(val) : ''}
|
|
62
|
+
</span>
|
|
63
|
+
<span className="text-muted-foreground text-xs line-clamp-1">
|
|
64
|
+
{secondary != null ? String(secondary) : ''}
|
|
65
|
+
</span>
|
|
66
|
+
</>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
case 'link': {
|
|
71
|
+
return (
|
|
72
|
+
<a
|
|
73
|
+
href="#"
|
|
74
|
+
className="text-primary hover:underline font-medium text-[13px] line-clamp-3"
|
|
75
|
+
onClick={(e) => e.preventDefault()}
|
|
76
|
+
>
|
|
77
|
+
{val != null ? String(val) : ''}
|
|
78
|
+
</a>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case 'status-badge': {
|
|
83
|
+
if (val == null || !col.statusMap) return <span className="text-[13px]">{val != null ? String(val) : ''}</span>
|
|
84
|
+
const status = col.statusMap[String(val)] ?? 'active'
|
|
85
|
+
return (
|
|
86
|
+
<Badge className={STATUS_COLORS[status]}>
|
|
87
|
+
{String(val)}
|
|
88
|
+
</Badge>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
case 'inventory': {
|
|
93
|
+
if (val == null || !col.levelFn) return null
|
|
94
|
+
const level = col.levelFn(Number(val))
|
|
95
|
+
if (mode === 'inventory-label') {
|
|
96
|
+
const label = col.labelMap?.[level] ?? level
|
|
97
|
+
return (
|
|
98
|
+
<Badge className={cn(INVENTORY_COLORS[level])}>
|
|
99
|
+
{label}
|
|
100
|
+
</Badge>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
const formatted = col.formatFn ? col.formatFn(Number(val)) : String(val)
|
|
104
|
+
return (
|
|
105
|
+
<Badge className={cn(INVENTORY_COLORS[level])}>
|
|
106
|
+
{formatted}
|
|
107
|
+
</Badge>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
case 'currency': {
|
|
112
|
+
if (val == null) return null
|
|
113
|
+
const currency = col.currencyField != null ? String(row[col.currencyField] ?? '') : ''
|
|
114
|
+
const formatted = Number(val).toLocaleString('de-DE', {
|
|
115
|
+
minimumFractionDigits: 2,
|
|
116
|
+
maximumFractionDigits: 2,
|
|
117
|
+
})
|
|
118
|
+
return (
|
|
119
|
+
<span className="tabular-nums font-medium text-[13px] whitespace-nowrap">
|
|
120
|
+
{formatted} {currency}
|
|
121
|
+
</span>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
case 'score-bar': {
|
|
126
|
+
return <ScoreBar value={val != null ? Number(val) : 0} />
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case 'favorite': {
|
|
130
|
+
const itemName = String(row['name'] ?? row['title'] ?? '')
|
|
131
|
+
return (
|
|
132
|
+
<FavoriteButton
|
|
133
|
+
pressed={isFavorite}
|
|
134
|
+
itemName={itemName}
|
|
135
|
+
onPressedChange={() => onToggleFavorite?.()}
|
|
136
|
+
/>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
case 'expand': {
|
|
141
|
+
const items = val as unknown[]
|
|
142
|
+
const count = Array.isArray(items) ? items.length : 0
|
|
143
|
+
if (count === 0) {
|
|
144
|
+
return <span className="text-[13px] text-muted-foreground">—</span>
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const ExpandIcon = col.expandIcon ? EXPAND_ICONS[col.expandIcon] : null
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<Button
|
|
151
|
+
variant="outline"
|
|
152
|
+
size="sm"
|
|
153
|
+
aria-expanded={isExpanded}
|
|
154
|
+
aria-label={col.expandLabel ?? colKey}
|
|
155
|
+
onClick={onToggleExpand}
|
|
156
|
+
className={cn(
|
|
157
|
+
'gap-1.5',
|
|
158
|
+
isExpanded && 'border-primary text-primary bg-primary/5',
|
|
159
|
+
)}
|
|
160
|
+
>
|
|
161
|
+
{ExpandIcon && <ExpandIcon className="size-3.5" aria-hidden="true" />}
|
|
162
|
+
{count}
|
|
163
|
+
<ChevronDown
|
|
164
|
+
className={cn('size-3.5 transition-transform duration-200', isExpanded && 'rotate-180')}
|
|
165
|
+
aria-hidden="true"
|
|
166
|
+
/>
|
|
167
|
+
</Button>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
default: {
|
|
172
|
+
return <span className="text-[13px]">{val != null ? String(val) : ''}</span>
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -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
|
+
}
|