@fastnd/components 1.0.33 → 1.0.34
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/examples/dashboard/ProjectList/ProjectList.tsx +6 -6
- package/dist/examples/dashboard/StatusDonutChart/StatusDonutChart.tsx +4 -4
- package/dist/examples/dashboard/StatusFilterLegend/StatusFilterLegend.tsx +1 -1
- package/dist/examples/dashboard/StatusOverview/StatusOverview.tsx +1 -1
- package/dist/examples/dashboard/constants.ts +8 -8
- package/dist/examples/data-visualization/CellRenderers/CellRenderers.tsx +41 -7
- package/dist/examples/data-visualization/ColumnConfigPopover/ColumnConfigPopover.tsx +1 -1
- package/dist/examples/data-visualization/DataCardView/DataCardView.tsx +23 -15
- package/dist/examples/data-visualization/DataListView/DataListView.tsx +48 -32
- package/dist/examples/data-visualization/DataTableView/DataTableView.tsx +62 -15
- package/dist/examples/data-visualization/constants.ts +5 -4
- package/dist/examples/data-visualization/types.ts +8 -0
- package/dist/fastnd-components.js +1 -1
- package/package.json +1 -1
|
@@ -10,11 +10,11 @@ import type { Project, ProjectStatus } from '../types'
|
|
|
10
10
|
import { STATUS_CONFIG } from '../constants'
|
|
11
11
|
|
|
12
12
|
const STATUS_BADGE_CLASSES: Record<ProjectStatus, string> = {
|
|
13
|
-
'neu': '
|
|
14
|
-
'offen': '
|
|
15
|
-
'in-prufung': '
|
|
16
|
-
'validierung': '
|
|
17
|
-
'abgeschlossen': '
|
|
13
|
+
'neu': 'border-primary',
|
|
14
|
+
'offen': 'border-muted-foreground',
|
|
15
|
+
'in-prufung': 'border-[#ebbe0d]',
|
|
16
|
+
'validierung': 'border-[#e8a026]',
|
|
17
|
+
'abgeschlossen': 'border-[#1ec489]',
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const STATIC_SORT: SortState = { column: null, direction: 'asc' }
|
|
@@ -75,7 +75,7 @@ const ProjectList = React.forwardRef<HTMLElement, ProjectListProps>(
|
|
|
75
75
|
render: (val) => {
|
|
76
76
|
const s = val as ProjectStatus
|
|
77
77
|
return (
|
|
78
|
-
<Badge variant="
|
|
78
|
+
<Badge variant="outline" className={STATUS_BADGE_CLASSES[s]}>
|
|
79
79
|
{STATUS_CONFIG[s].label}
|
|
80
80
|
</Badge>
|
|
81
81
|
)
|
|
@@ -58,7 +58,7 @@ const StatusDonutChart = React.forwardRef<HTMLDivElement, StatusDonutChartProps>
|
|
|
58
58
|
{...props}
|
|
59
59
|
>
|
|
60
60
|
{/* Relative wrapper so the center label overlay can be positioned absolutely */}
|
|
61
|
-
<div className="relative mx-auto aspect-square max-h-[
|
|
61
|
+
<div className="relative mx-auto aspect-square max-h-[160px]">
|
|
62
62
|
<ChartContainer config={chartConfig} className="size-full">
|
|
63
63
|
<PieChart>
|
|
64
64
|
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
|
|
@@ -66,8 +66,8 @@ const StatusDonutChart = React.forwardRef<HTMLDivElement, StatusDonutChartProps>
|
|
|
66
66
|
data={chartData}
|
|
67
67
|
dataKey="count"
|
|
68
68
|
nameKey="status"
|
|
69
|
-
innerRadius={
|
|
70
|
-
outerRadius={
|
|
69
|
+
innerRadius={48}
|
|
70
|
+
outerRadius={68}
|
|
71
71
|
strokeWidth={5}
|
|
72
72
|
className="cursor-pointer"
|
|
73
73
|
onClick={handlePieClick}
|
|
@@ -88,7 +88,7 @@ const StatusDonutChart = React.forwardRef<HTMLDivElement, StatusDonutChartProps>
|
|
|
88
88
|
{/* Center label — rendered as HTML so it is always in the DOM (recharts SVG labels are invisible in jsdom) */}
|
|
89
89
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-0.5">
|
|
90
90
|
<span
|
|
91
|
-
className="text-
|
|
91
|
+
className="text-2xl font-bold text-foreground"
|
|
92
92
|
style={{ fontFamily: 'var(--font-clash)' }}
|
|
93
93
|
>
|
|
94
94
|
{total.toLocaleString()}
|
|
@@ -43,7 +43,7 @@ const StatusFilterLegend = React.forwardRef<HTMLDivElement, StatusFilterLegendPr
|
|
|
43
43
|
data-filter={filter}
|
|
44
44
|
onClick={() => onFilterChange(filter)}
|
|
45
45
|
className={cn(
|
|
46
|
-
'flex items-center justify-between w-full px-3 py-
|
|
46
|
+
'flex items-center justify-between w-full px-3 py-1.5 rounded-md border border-transparent transition-colors cursor-pointer text-sm outline-none focus-visible:bg-muted',
|
|
47
47
|
isActive ? 'bg-muted border-border' : 'hover:bg-muted',
|
|
48
48
|
)}
|
|
49
49
|
>
|
|
@@ -21,12 +21,12 @@ export const MOCK_PROJECTS: Project[] = [
|
|
|
21
21
|
|
|
22
22
|
export const MOCK_PROJECTS_EXTENDED: Project[] = [
|
|
23
23
|
...MOCK_PROJECTS,
|
|
24
|
-
{ id: '7', name: 'Supply Chain Visibility Platform',
|
|
25
|
-
{ id: '8', name: 'HR Self-Service Portal',
|
|
26
|
-
{ id: '9', name: 'IoT Monitoring Dashboard',
|
|
27
|
-
{ id: '10', name: '
|
|
28
|
-
{ id: '11', name: 'Customer Loyalty App',
|
|
29
|
-
{ id: '12', name: 'Predictive Maintenance Engine',
|
|
30
|
-
{ id: '13', name: 'Compliance Reporting Suite',
|
|
31
|
-
{ id: '14', name: 'Field Service
|
|
24
|
+
{ id: '7', name: 'Supply Chain Visibility & Tracking Platform', client: 'Bayerische LogiTrans Handelsgesellschaft mbH', application: 'Next.js 14 / tRPC / PostgreSQL', status: 'in-prufung', isFavorite: true, createdAt: '2024-12-03', updatedAt: d(2) },
|
|
25
|
+
{ id: '8', name: 'HR Self-Service & Workforce Management Portal', client: 'Personalwerk Süddeutschland AG', application: 'Angular 17 / .NET Core 8 / Azure AD', status: 'offen', isFavorite: false, createdAt: '2025-01-08', updatedAt: d(5) },
|
|
26
|
+
{ id: '9', name: 'Echtzeit IoT Monitoring & Alerting Dashboard', client: 'SmartFactory Automatisierungstechnik SE', application: 'Vue.js 3 / InfluxDB / Grafana', status: 'validierung', isFavorite: false, createdAt: '2024-10-14', updatedAt: d(9) },
|
|
27
|
+
{ id: '10', name: 'Digitales Dokumenten- & Archivierungssystem', client: 'Kanzlei Bauer, Hoffmann & Partner GbR', application: 'SharePoint Online / Power Automate', status: 'abgeschlossen', isFavorite: false, createdAt: '2024-06-01', updatedAt: d(30) },
|
|
28
|
+
{ id: '11', name: 'Multi-Channel Customer Loyalty & Rewards App', client: 'Retail Chain Deutschland GmbH & Co. KG', application: 'React Native / Expo / Firebase', status: 'neu', isFavorite: false, createdAt: '2025-03-20', updatedAt: d(0) },
|
|
29
|
+
{ id: '12', name: 'KI-gestützte Predictive Maintenance Engine', client: 'IndustrieWerk Maschinenbau KG', application: 'Python 3.12 / FastAPI / PyTorch / MLflow',status: 'in-prufung', isFavorite: true, createdAt: '2024-09-17', updatedAt: d(4) },
|
|
30
|
+
{ id: '13', name: 'Automatisierte Compliance & Audit Reporting Suite', client: 'RegulaCorp Financial Services AG', application: 'Power BI Embedded / Azure Synapse', status: 'offen', isFavorite: false, createdAt: '2025-02-25', updatedAt: d(11) },
|
|
31
|
+
{ id: '14', name: 'Mobile Field Service & Wartungsmanagement', client: 'TechService Außendienst GmbH', application: 'Salesforce Field Service / MuleSoft', status: 'validierung', isFavorite: true, createdAt: '2024-07-30', updatedAt: d(6) },
|
|
32
32
|
]
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { ChevronDown, ArrowLeftRight, Sparkles } from 'lucide-react'
|
|
2
|
+
import { ChevronDown, ArrowLeftRight, Sparkles, FolderPlus, BookmarkPlus, Trash2 } from 'lucide-react'
|
|
3
3
|
import { Badge } from '@/components/ui/badge'
|
|
4
4
|
import { Button } from '@/components/ui/button'
|
|
5
5
|
import { ScoreBar } from '@/components/ScoreBar/ScoreBar'
|
|
@@ -10,10 +10,10 @@ import type { ColumnDef, RenderCellOptions } from '../types'
|
|
|
10
10
|
export type { RenderCellOptions }
|
|
11
11
|
|
|
12
12
|
const STATUS_COLORS: Record<string, string> = {
|
|
13
|
-
active:
|
|
14
|
-
nrnd:
|
|
15
|
-
eol:
|
|
16
|
-
production: '
|
|
13
|
+
active: 'border-[var(--lifecycle-active)]',
|
|
14
|
+
nrnd: 'border-[var(--lifecycle-nrnd)]',
|
|
15
|
+
eol: 'border-[var(--lifecycle-eol)]',
|
|
16
|
+
production: 'border-[var(--lifecycle-production)]',
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const INVENTORY_COLORS: Record<string, string> = {
|
|
@@ -33,7 +33,7 @@ export function renderCell(
|
|
|
33
33
|
row: Record<string, unknown>,
|
|
34
34
|
options: RenderCellOptions = {},
|
|
35
35
|
): React.ReactNode {
|
|
36
|
-
const { mode = 'default', isExpanded = false, isFavorite = false, onToggleExpand, onToggleFavorite } = options
|
|
36
|
+
const { mode = 'default', isExpanded = false, isFavorite = false, onToggleExpand, onToggleFavorite, onAddToProject, onAddToCollection, onDelete } = options
|
|
37
37
|
const val = row[colKey]
|
|
38
38
|
|
|
39
39
|
if (col.render) return col.render(val, row, options)
|
|
@@ -79,7 +79,7 @@ export function renderCell(
|
|
|
79
79
|
if (val == null || !col.statusMap) return <span className="text-[13px]">{val != null ? String(val) : ''}</span>
|
|
80
80
|
const status = col.statusMap[String(val)] ?? 'active'
|
|
81
81
|
return (
|
|
82
|
-
<Badge className={STATUS_COLORS[status]}>
|
|
82
|
+
<Badge variant="outline" className={STATUS_COLORS[status]}>
|
|
83
83
|
{String(val)}
|
|
84
84
|
</Badge>
|
|
85
85
|
)
|
|
@@ -164,6 +164,40 @@ export function renderCell(
|
|
|
164
164
|
)
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
case 'actions': {
|
|
168
|
+
return (
|
|
169
|
+
<div className="flex items-center justify-end gap-0.5">
|
|
170
|
+
<Button
|
|
171
|
+
variant="ghost"
|
|
172
|
+
size="icon"
|
|
173
|
+
className="size-7 text-muted-foreground hover:text-foreground"
|
|
174
|
+
aria-label="Zu Projekt hinzufügen"
|
|
175
|
+
onClick={(e) => { e.stopPropagation(); onAddToProject?.() }}
|
|
176
|
+
>
|
|
177
|
+
<FolderPlus className="size-3.5" aria-hidden="true" />
|
|
178
|
+
</Button>
|
|
179
|
+
<Button
|
|
180
|
+
variant="ghost"
|
|
181
|
+
size="icon"
|
|
182
|
+
className="size-7 text-muted-foreground hover:text-foreground"
|
|
183
|
+
aria-label="Zu Sammlung hinzufügen"
|
|
184
|
+
onClick={(e) => { e.stopPropagation(); onAddToCollection?.() }}
|
|
185
|
+
>
|
|
186
|
+
<BookmarkPlus className="size-3.5" aria-hidden="true" />
|
|
187
|
+
</Button>
|
|
188
|
+
<Button
|
|
189
|
+
variant="ghost"
|
|
190
|
+
size="icon"
|
|
191
|
+
className="size-7 text-muted-foreground hover:text-destructive"
|
|
192
|
+
aria-label="Produkt löschen"
|
|
193
|
+
onClick={(e) => { e.stopPropagation(); onDelete?.() }}
|
|
194
|
+
>
|
|
195
|
+
<Trash2 className="size-3.5" aria-hidden="true" />
|
|
196
|
+
</Button>
|
|
197
|
+
</div>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
167
201
|
default: {
|
|
168
202
|
return <span className="text-[13px]">{val != null ? String(val) : ''}</span>
|
|
169
203
|
}
|
|
@@ -106,7 +106,7 @@ export function ColumnConfigPopover({
|
|
|
106
106
|
>
|
|
107
107
|
<SortableContext items={columnOrder} strategy={verticalListSortingStrategy}>
|
|
108
108
|
<div className="p-1">
|
|
109
|
-
{columnOrder.map((key) => (
|
|
109
|
+
{columnOrder.filter((key) => columns[key]?.configurable !== false).map((key) => (
|
|
110
110
|
<SortableColumnItem
|
|
111
111
|
key={key}
|
|
112
112
|
colKey={key}
|
|
@@ -86,6 +86,7 @@ function DataCardView({
|
|
|
86
86
|
}: DataCardViewProps) {
|
|
87
87
|
const gridRef = useRef<HTMLDivElement>(null)
|
|
88
88
|
const expandFields = getExpandFields(layout)
|
|
89
|
+
const actionsColKey = Object.keys(columns).find((k) => columns[k]?.type === 'actions')
|
|
89
90
|
|
|
90
91
|
useLayoutEffect(() => {
|
|
91
92
|
if (gridRef.current) {
|
|
@@ -172,22 +173,29 @@ function DataCardView({
|
|
|
172
173
|
</div>
|
|
173
174
|
)}
|
|
174
175
|
|
|
175
|
-
{/* Footer — expand buttons */}
|
|
176
|
-
{expandFields.length > 0 && (
|
|
176
|
+
{/* Footer — expand buttons + actions */}
|
|
177
|
+
{(expandFields.length > 0 || actionsColKey) && (
|
|
177
178
|
<div className="mt-auto p-4 pt-3 border-t border-border">
|
|
178
|
-
<div className="flex gap-2">
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
{
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
179
|
+
<div className="flex items-center justify-between gap-2">
|
|
180
|
+
<div className="flex gap-2">
|
|
181
|
+
{expandFields.map((ef) => {
|
|
182
|
+
const col = columns[ef]
|
|
183
|
+
if (!col) return null
|
|
184
|
+
const expansionKey = `${rowId}::${ef}`
|
|
185
|
+
return (
|
|
186
|
+
<React.Fragment key={ef}>
|
|
187
|
+
{renderCell(ef, col, row, {
|
|
188
|
+
isExpanded: expandedRows.has(expansionKey),
|
|
189
|
+
onToggleExpand: () => onToggleExpansion(rowId, ef),
|
|
190
|
+
})}
|
|
191
|
+
</React.Fragment>
|
|
192
|
+
)
|
|
193
|
+
})}
|
|
194
|
+
</div>
|
|
195
|
+
{actionsColKey && renderCell(actionsColKey, columns[actionsColKey], row, {
|
|
196
|
+
onAddToProject: () => {},
|
|
197
|
+
onAddToCollection: () => {},
|
|
198
|
+
onDelete: () => {},
|
|
191
199
|
})}
|
|
192
200
|
</div>
|
|
193
201
|
</div>
|
|
@@ -140,22 +140,31 @@ function ListHeader({ columns, layout }: ListHeaderProps) {
|
|
|
140
140
|
: (titleCol?.label ?? '')
|
|
141
141
|
|
|
142
142
|
return (
|
|
143
|
-
<div className="flex items-center gap-3 px-4 py-
|
|
143
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-border bg-secondary">
|
|
144
144
|
{/* Placeholder matching FavoriteButton size */}
|
|
145
145
|
<div className="size-[28px] flex-none" aria-hidden="true" />
|
|
146
|
-
<div className="flex-1 min-w-0 text-xs font-medium text-
|
|
146
|
+
<div className="flex-1 min-w-0 text-xs font-medium text-foreground uppercase tracking-[0.03em]">
|
|
147
147
|
{titleLabel}
|
|
148
148
|
</div>
|
|
149
149
|
{layout.badgeFields.map((field) => (
|
|
150
|
-
<span
|
|
150
|
+
<span
|
|
151
|
+
key={field}
|
|
152
|
+
style={{ minWidth: columns[field]?.badgeColumnWidth ?? 96 }}
|
|
153
|
+
className="shrink-0 text-xs font-medium text-foreground uppercase tracking-[0.03em] text-right"
|
|
154
|
+
>
|
|
151
155
|
{columns[field]?.label ?? ''}
|
|
152
156
|
</span>
|
|
153
157
|
))}
|
|
154
158
|
{expandFields.map((field) => (
|
|
155
|
-
<span key={field} className="shrink-0 text-xs font-medium text-
|
|
159
|
+
<span key={field} className="shrink-0 text-xs font-medium text-foreground uppercase tracking-[0.03em]">
|
|
156
160
|
{columns[field]?.expandLabel ?? columns[field]?.label ?? ''}
|
|
157
161
|
</span>
|
|
158
162
|
))}
|
|
163
|
+
{Object.values(columns).some((c) => c.type === 'actions') && (
|
|
164
|
+
<span className="shrink-0 text-xs font-medium text-foreground uppercase tracking-[0.03em]">
|
|
165
|
+
{Object.values(columns).find((c) => c.type === 'actions')?.label ?? 'Aktionen'}
|
|
166
|
+
</span>
|
|
167
|
+
)}
|
|
159
168
|
</div>
|
|
160
169
|
)
|
|
161
170
|
}
|
|
@@ -174,6 +183,7 @@ function DataListView({
|
|
|
174
183
|
const expandFields = layout.expandFields ?? (layout.expandField ? [layout.expandField] : [])
|
|
175
184
|
const titleCol = columns[layout.titleField]
|
|
176
185
|
const isDoubleText = titleCol?.type === 'double-text'
|
|
186
|
+
const actionsColKey = Object.keys(columns).find((k) => columns[k]?.type === 'actions')
|
|
177
187
|
|
|
178
188
|
return (
|
|
179
189
|
<div
|
|
@@ -190,9 +200,9 @@ function DataListView({
|
|
|
190
200
|
|
|
191
201
|
return (
|
|
192
202
|
<div key={rowId}>
|
|
193
|
-
{/*
|
|
194
|
-
<div className="flex items-
|
|
195
|
-
<div className="flex-none">
|
|
203
|
+
{/* Row: Fav | Title + dot-meta | Fixed-width badge columns | Expand */}
|
|
204
|
+
<div className="flex items-start gap-3 px-4 py-3 hover:bg-accent transition-colors">
|
|
205
|
+
<div className="flex-none mt-0.5">
|
|
196
206
|
<FavoriteButton
|
|
197
207
|
pressed={favorites.has(rowId)}
|
|
198
208
|
itemName={titleValue != null ? String(titleValue) : ''}
|
|
@@ -200,45 +210,44 @@ function DataListView({
|
|
|
200
210
|
/>
|
|
201
211
|
</div>
|
|
202
212
|
|
|
203
|
-
{/* Title block grows
|
|
213
|
+
{/* Title block — grows, no fixed width */}
|
|
204
214
|
<div className="flex-1 min-w-0">
|
|
205
215
|
<div className="font-semibold text-sm truncate">
|
|
206
216
|
{titleValue != null ? String(titleValue) : ''}
|
|
207
217
|
</div>
|
|
208
|
-
{/*
|
|
209
|
-
{(subtitleValue != null || layout.metaFields.
|
|
210
|
-
<
|
|
211
|
-
{
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
<span
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
{String(v)}
|
|
225
|
-
</span>
|
|
226
|
-
)
|
|
227
|
-
})}
|
|
228
|
-
</div>
|
|
218
|
+
{/* Meta line: subtitle + metaFields, dot-separated, each segment truncated */}
|
|
219
|
+
{(subtitleValue != null || layout.metaFields.some(f => row[f] != null && row[f] !== '')) && (
|
|
220
|
+
<p className="text-xs text-muted-foreground mt-0.5 flex min-w-0">
|
|
221
|
+
{[
|
|
222
|
+
...(subtitleValue != null ? [String(subtitleValue)] : []),
|
|
223
|
+
...layout.metaFields
|
|
224
|
+
.map(f => row[f])
|
|
225
|
+
.filter(v => v != null && v !== '')
|
|
226
|
+
.map(String),
|
|
227
|
+
].map((item, i) => (
|
|
228
|
+
<React.Fragment key={i}>
|
|
229
|
+
{i > 0 && <span className="shrink-0 px-1">·</span>}
|
|
230
|
+
<span className="truncate max-w-[160px]">{item}</span>
|
|
231
|
+
</React.Fragment>
|
|
232
|
+
))}
|
|
233
|
+
</p>
|
|
229
234
|
)}
|
|
230
235
|
</div>
|
|
231
236
|
|
|
232
|
-
{/*
|
|
237
|
+
{/* Badge columns — fixed min-width per column for vertical alignment */}
|
|
233
238
|
{layout.badgeFields.map((field) => {
|
|
234
239
|
const col = columns[field]
|
|
235
240
|
if (!col) return null
|
|
236
241
|
return (
|
|
237
|
-
<
|
|
242
|
+
<div
|
|
243
|
+
key={field}
|
|
244
|
+
style={{ minWidth: col.badgeColumnWidth ?? 96 }}
|
|
245
|
+
className="flex justify-end items-start"
|
|
246
|
+
>
|
|
238
247
|
{renderCell(field, col, row, {
|
|
239
248
|
mode: col.type === 'inventory' ? 'inventory-label' : 'default',
|
|
240
249
|
})}
|
|
241
|
-
</
|
|
250
|
+
</div>
|
|
242
251
|
)
|
|
243
252
|
})}
|
|
244
253
|
|
|
@@ -257,6 +266,13 @@ function DataListView({
|
|
|
257
266
|
</React.Fragment>
|
|
258
267
|
)
|
|
259
268
|
})}
|
|
269
|
+
|
|
270
|
+
{/* Actions */}
|
|
271
|
+
{actionsColKey && renderCell(actionsColKey, columns[actionsColKey], row, {
|
|
272
|
+
onAddToProject: () => {},
|
|
273
|
+
onAddToCollection: () => {},
|
|
274
|
+
onDelete: () => {},
|
|
275
|
+
})}
|
|
260
276
|
</div>
|
|
261
277
|
|
|
262
278
|
{/* Expansion panels — full width, below the row */}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useRef, useCallback } from 'react'
|
|
1
|
+
import React, { useState, useRef, useCallback, useLayoutEffect } from 'react'
|
|
2
2
|
import { ChevronUp, ChevronDown, ArrowLeftRight, Sparkles, Plus, Star } from 'lucide-react'
|
|
3
3
|
import {
|
|
4
4
|
Table,
|
|
@@ -36,6 +36,7 @@ const DEFAULT_MIN_WIDTHS: Partial<Record<string, number>> = {
|
|
|
36
36
|
currency: 100,
|
|
37
37
|
'double-text': 200,
|
|
38
38
|
link: 140,
|
|
39
|
+
actions: 112,
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
// Column types that should wrap text (line-clamp handles truncation)
|
|
@@ -63,7 +64,7 @@ function ExpansionCell({ mainColKey, expandColKey, mainCol, mapLookup, item }: E
|
|
|
63
64
|
if (mainColKey === expandColKey) {
|
|
64
65
|
return (
|
|
65
66
|
<Button
|
|
66
|
-
variant="
|
|
67
|
+
variant="outline"
|
|
67
68
|
size="icon"
|
|
68
69
|
className="size-7"
|
|
69
70
|
aria-label="Hinzufügen"
|
|
@@ -84,7 +85,7 @@ function ExpansionCell({ mainColKey, expandColKey, mainCol, mapLookup, item }: E
|
|
|
84
85
|
const sec = item[ec.secondaryKey]
|
|
85
86
|
return (
|
|
86
87
|
<>
|
|
87
|
-
<span className={cn('font-
|
|
88
|
+
<span className={cn('font-medium text-[13px] line-clamp-2', ec.bold && 'font-bold')}>
|
|
88
89
|
{val != null ? String(val) : ''}
|
|
89
90
|
</span>
|
|
90
91
|
<span className="text-muted-foreground text-xs line-clamp-1">
|
|
@@ -111,17 +112,13 @@ function ExpansionCell({ mainColKey, expandColKey, mainCol, mapLookup, item }: E
|
|
|
111
112
|
if (mainCol.type === 'status-badge' && mainCol.statusMap && val != null) {
|
|
112
113
|
const status = mainCol.statusMap[String(val)] ?? 'active'
|
|
113
114
|
const STATUS_COLORS: Record<string, string> = {
|
|
114
|
-
active:
|
|
115
|
-
nrnd:
|
|
116
|
-
eol:
|
|
117
|
-
production: '
|
|
115
|
+
active: 'border-[var(--lifecycle-active)]',
|
|
116
|
+
nrnd: 'border-[var(--lifecycle-nrnd)]',
|
|
117
|
+
eol: 'border-[var(--lifecycle-eol)]',
|
|
118
|
+
production: 'border-[var(--lifecycle-production)]',
|
|
118
119
|
}
|
|
119
120
|
return (
|
|
120
|
-
<Badge className={
|
|
121
|
-
<span
|
|
122
|
-
className={cn('size-1.5 rounded-full', `bg-[var(--lifecycle-${status})]`)}
|
|
123
|
-
aria-hidden="true"
|
|
124
|
-
/>
|
|
121
|
+
<Badge variant="outline" className={STATUS_COLORS[status]}>
|
|
125
122
|
{String(val)}
|
|
126
123
|
</Badge>
|
|
127
124
|
)
|
|
@@ -155,9 +152,10 @@ interface ExpansionSectionProps {
|
|
|
155
152
|
colKey: string
|
|
156
153
|
visibleColumns: string[]
|
|
157
154
|
columns: Record<string, ColumnDef>
|
|
155
|
+
columnWidths: Record<string, number>
|
|
158
156
|
}
|
|
159
157
|
|
|
160
|
-
function ExpansionSection({ row, colKey, visibleColumns, columns }: ExpansionSectionProps) {
|
|
158
|
+
function ExpansionSection({ row, colKey, visibleColumns, columns, columnWidths }: ExpansionSectionProps) {
|
|
161
159
|
const col = columns[colKey]
|
|
162
160
|
const items = row[colKey] as Record<string, unknown>[] | undefined
|
|
163
161
|
if (!items?.length || !col.expandColumns) return null
|
|
@@ -201,6 +199,7 @@ function ExpansionSection({ row, colKey, visibleColumns, columns }: ExpansionSec
|
|
|
201
199
|
mainCol.hideTablet && 'hidden lg:table-cell',
|
|
202
200
|
mainCol.hideMobile && 'hidden sm:table-cell',
|
|
203
201
|
)}
|
|
202
|
+
style={columnWidths[mainKey] != null ? { width: columnWidths[mainKey] } : undefined}
|
|
204
203
|
>
|
|
205
204
|
<ExpansionCell
|
|
206
205
|
mainColKey={mainKey}
|
|
@@ -248,6 +247,12 @@ export function DataTableView({
|
|
|
248
247
|
// Column widths: keyed by column key, value in px
|
|
249
248
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({})
|
|
250
249
|
|
|
250
|
+
// Ref to the header <tr> for snapshotting rendered column widths
|
|
251
|
+
const headerRowRef = useRef<HTMLTableRowElement>(null)
|
|
252
|
+
|
|
253
|
+
// Tracks the previous visibleColumns reference to detect changes
|
|
254
|
+
const prevVisibleRef = useRef<string[]>(visibleColumns)
|
|
255
|
+
|
|
251
256
|
// Resize tracking ref (not state — no re-render during drag)
|
|
252
257
|
const resizeRef = useRef<{
|
|
253
258
|
colKey: string
|
|
@@ -255,6 +260,31 @@ export function DataTableView({
|
|
|
255
260
|
startWidth: number
|
|
256
261
|
} | null>(null)
|
|
257
262
|
|
|
263
|
+
// Measure-then-lock: snapshot <th> widths while table is still table-layout:auto,
|
|
264
|
+
// then switch to table-fixed via colgroup so expansion rows can never widen columns.
|
|
265
|
+
useLayoutEffect(() => {
|
|
266
|
+
const prev = prevVisibleRef.current
|
|
267
|
+
const changed =
|
|
268
|
+
prev.length !== visibleColumns.length || prev.some((k, i) => k !== visibleColumns[i])
|
|
269
|
+
if (changed) {
|
|
270
|
+
// Column set changed — clear all widths so the table re-snapshots with natural auto sizing
|
|
271
|
+
prevVisibleRef.current = visibleColumns
|
|
272
|
+
setColumnWidths({})
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
if (!headerRowRef.current) return
|
|
276
|
+
const ths = Array.from(headerRowRef.current.querySelectorAll('th')) as HTMLTableCellElement[]
|
|
277
|
+
setColumnWidths((prev) => {
|
|
278
|
+
const next = { ...prev }
|
|
279
|
+
visibleColumns.forEach((colKey, i) => {
|
|
280
|
+
if (next[colKey] == null && ths[i]) {
|
|
281
|
+
next[colKey] = ths[i].getBoundingClientRect().width
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
return next
|
|
285
|
+
})
|
|
286
|
+
}, [visibleColumns])
|
|
287
|
+
|
|
258
288
|
const handleResizeMouseDown = useCallback(
|
|
259
289
|
(e: React.MouseEvent, colKey: string) => {
|
|
260
290
|
e.preventDefault()
|
|
@@ -288,6 +318,10 @@ export function DataTableView({
|
|
|
288
318
|
// Collect expand column keys for the current domain
|
|
289
319
|
const expandColKeys = visibleColumns.filter((k) => columns[k]?.type === 'expand')
|
|
290
320
|
|
|
321
|
+
// Switch to table-fixed only after all columns have been measured — prevents content in
|
|
322
|
+
// expansion rows from ever forcing a column to widen.
|
|
323
|
+
const isFixed = visibleColumns.every((k) => columnWidths[k] != null)
|
|
324
|
+
|
|
291
325
|
return (
|
|
292
326
|
<div className={cn('mx-4 mb-4 border border-border rounded-md overflow-hidden', className)}>
|
|
293
327
|
<div
|
|
@@ -296,9 +330,18 @@ export function DataTableView({
|
|
|
296
330
|
aria-label="Datentabelle"
|
|
297
331
|
tabIndex={0}
|
|
298
332
|
>
|
|
299
|
-
<Table>
|
|
333
|
+
<Table className={isFixed ? 'table-fixed' : undefined}>
|
|
334
|
+
{/* colgroup enforces widths on ALL rows (including expansion rows) */}
|
|
335
|
+
<colgroup>
|
|
336
|
+
{visibleColumns.map((colKey) => (
|
|
337
|
+
<col
|
|
338
|
+
key={colKey}
|
|
339
|
+
style={columnWidths[colKey] != null ? { width: columnWidths[colKey] } : undefined}
|
|
340
|
+
/>
|
|
341
|
+
))}
|
|
342
|
+
</colgroup>
|
|
300
343
|
<TableHeader>
|
|
301
|
-
<TableRow className="bg-secondary hover:bg-secondary">
|
|
344
|
+
<TableRow ref={headerRowRef} className="bg-secondary hover:bg-secondary">
|
|
302
345
|
{visibleColumns.map((colKey) => {
|
|
303
346
|
const col = columns[colKey]
|
|
304
347
|
if (!col) return null
|
|
@@ -427,6 +470,9 @@ export function DataTableView({
|
|
|
427
470
|
isFavorite,
|
|
428
471
|
onToggleExpand: () => onToggleExpansion(rowId, colKey),
|
|
429
472
|
onToggleFavorite: () => onToggleFavorite(rowId),
|
|
473
|
+
onAddToProject: () => {},
|
|
474
|
+
onAddToCollection: () => {},
|
|
475
|
+
onDelete: () => {},
|
|
430
476
|
})}
|
|
431
477
|
</TableCell>
|
|
432
478
|
)
|
|
@@ -441,6 +487,7 @@ export function DataTableView({
|
|
|
441
487
|
colKey={expColKey}
|
|
442
488
|
visibleColumns={visibleColumns}
|
|
443
489
|
columns={columns}
|
|
490
|
+
columnWidths={columnWidths}
|
|
444
491
|
/>
|
|
445
492
|
))}
|
|
446
493
|
</React.Fragment>
|
|
@@ -3,8 +3,8 @@ import type { ColumnDef, DomainConfig, DomainKey, DomainLayout } from './types'
|
|
|
3
3
|
// ===== PRODUCT DOMAIN =====
|
|
4
4
|
|
|
5
5
|
const PRODUCT_COLUMNS: Record<string, ColumnDef> = {
|
|
6
|
-
product_category: { label: '
|
|
7
|
-
product_group: { label: '
|
|
6
|
+
product_category: { label: 'Kategorie', type: 'text', sortable: true, filterable: true, primaryFilter: true, visible: true, searchable: true },
|
|
7
|
+
product_group: { label: 'Gruppe', type: 'text', sortable: true, filterable: true, primaryFilter: true, visible: true, searchable: true },
|
|
8
8
|
part_number: { label: 'Produkt', type: 'double-text', sortable: true, filterable: false, visible: true, secondary: 'product_family_name', searchable: true },
|
|
9
9
|
manufacturer_name: { label: 'Hersteller', type: 'link', sortable: true, filterable: true, primaryFilter: false, visible: true, searchable: true },
|
|
10
10
|
description: { label: 'Beschreibung', type: 'text', sortable: false, filterable: false, visible: true, searchable: true, rowLines: 2 },
|
|
@@ -32,7 +32,7 @@ const PRODUCT_COLUMNS: Record<string, ColumnDef> = {
|
|
|
32
32
|
expandColumns: [
|
|
33
33
|
{ key: 'alt_product_category', label: 'Kategorie', mapTo: 'product_category' },
|
|
34
34
|
{ key: 'alt_family_name', label: 'Familie', mapTo: 'product_group' },
|
|
35
|
-
{ key: 'alt_part_number', label: 'Teilenummer',
|
|
35
|
+
{ key: 'alt_part_number', label: 'Teilenummer', mapTo: 'part_number', secondaryKey: 'alt_product_family_name' },
|
|
36
36
|
{ key: 'alt_manufacturer', label: 'Hersteller', mapTo: 'manufacturer_name' },
|
|
37
37
|
{ key: 'alt_description', label: 'Beschreibung', mapTo: 'description' },
|
|
38
38
|
{ key: 'alt_lifecycle', label: 'Status', mapTo: 'lifecycle' },
|
|
@@ -54,7 +54,7 @@ const PRODUCT_COLUMNS: Record<string, ColumnDef> = {
|
|
|
54
54
|
expandColumns: [
|
|
55
55
|
{ key: 'cross_sell_product_category', label: 'Kategorie', mapTo: 'product_category' },
|
|
56
56
|
{ key: 'cross_sell_family_name', label: 'Familie', mapTo: 'product_group' },
|
|
57
|
-
{ key: 'cross_sell_part_number', label: 'Teilenummer',
|
|
57
|
+
{ key: 'cross_sell_part_number', label: 'Teilenummer', mapTo: 'part_number', secondaryKey: 'cross_sell_product_family_name' },
|
|
58
58
|
{ key: 'cross_sell_manufacturer', label: 'Hersteller', mapTo: 'manufacturer_name' },
|
|
59
59
|
{ key: 'cross_sell_description', label: 'Beschreibung', mapTo: 'description' },
|
|
60
60
|
{ key: 'recommendation_source', label: 'Quelle', muted: true, mapTo: 'lifecycle' },
|
|
@@ -83,6 +83,7 @@ const PRODUCT_COLUMNS: Record<string, ColumnDef> = {
|
|
|
83
83
|
return v < 1000
|
|
84
84
|
},
|
|
85
85
|
},
|
|
86
|
+
actions: { label: 'Aktionen', type: 'actions', sortable: false, filterable: false, visible: true, configurable: false },
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
const PRODUCT_LAYOUT: DomainLayout = {
|
|
@@ -12,6 +12,7 @@ export type CellType =
|
|
|
12
12
|
| 'favorite'
|
|
13
13
|
| 'expand'
|
|
14
14
|
| 'score-bar'
|
|
15
|
+
| 'actions'
|
|
15
16
|
|
|
16
17
|
export type ViewMode = 'table' | 'list' | 'card'
|
|
17
18
|
|
|
@@ -40,6 +41,9 @@ export interface RenderCellOptions {
|
|
|
40
41
|
isFavorite?: boolean
|
|
41
42
|
onToggleExpand?: () => void
|
|
42
43
|
onToggleFavorite?: () => void
|
|
44
|
+
onAddToProject?: () => void
|
|
45
|
+
onAddToCollection?: () => void
|
|
46
|
+
onDelete?: () => void
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
export interface ColumnDef {
|
|
@@ -49,6 +53,8 @@ export interface ColumnDef {
|
|
|
49
53
|
filterable: boolean
|
|
50
54
|
primaryFilter?: boolean
|
|
51
55
|
visible: boolean
|
|
56
|
+
/** When false, column is excluded from the column-config popover (always visible, not reorderable) */
|
|
57
|
+
configurable?: boolean
|
|
52
58
|
searchable?: boolean
|
|
53
59
|
secondary?: string
|
|
54
60
|
currencyField?: string
|
|
@@ -67,6 +73,8 @@ export interface ColumnDef {
|
|
|
67
73
|
hideTablet?: boolean
|
|
68
74
|
hideMobile?: boolean
|
|
69
75
|
rowLines?: number
|
|
76
|
+
/** Min-width in px for badge column alignment in DataListView. Default: 96 */
|
|
77
|
+
badgeColumnWidth?: number
|
|
70
78
|
render?: (val: unknown, row: Record<string, unknown>, opts: RenderCellOptions) => React.ReactNode
|
|
71
79
|
}
|
|
72
80
|
|