@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.
@@ -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': 'bg-primary/10 text-primary border-transparent',
14
- 'offen': 'bg-muted-foreground/10 text-muted-foreground border-transparent',
15
- 'in-prufung': 'bg-[#ebbe0d]/15 text-[#8a7100] border-transparent',
16
- 'validierung': 'bg-[#e8a026]/15 text-[#8a5a00] border-transparent',
17
- 'abgeschlossen': 'bg-[#1ec489]/15 text-[#0a6e44] border-transparent',
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="secondary" className={STATUS_BADGE_CLASSES[s]}>
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-[200px]">
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={60}
70
- outerRadius={85}
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-3xl font-bold text-foreground"
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-2 rounded-md border border-transparent transition-colors cursor-pointer text-sm outline-none focus-visible:bg-muted',
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
  >
@@ -22,7 +22,7 @@ const StatusOverview = React.forwardRef<HTMLElement, StatusOverviewProps>(
22
22
  className={cn(className)}
23
23
  {...props}
24
24
  >
25
- <Card>
25
+ <Card className="h-full py-4 gap-3">
26
26
  <CardHeader>
27
27
  <CardTitle
28
28
  className="text-2xl font-medium"
@@ -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', client: 'LogiTrans GmbH', application: 'Next.js / GraphQL', status: 'in-prufung', isFavorite: true, createdAt: '2024-12-03', updatedAt: d(2) },
25
- { id: '8', name: 'HR Self-Service Portal', client: 'Personalwerk AG', application: 'Angular / .NET Core', status: 'offen', isFavorite: false, createdAt: '2025-01-08', updatedAt: d(5) },
26
- { id: '9', name: 'IoT Monitoring Dashboard', client: 'SmartFactory SE', application: 'Vue.js / InfluxDB', status: 'validierung', isFavorite: false, createdAt: '2024-10-14', updatedAt: d(9) },
27
- { id: '10', name: 'Document Management System', client: 'Kanzlei Bauer & Co', application: 'SharePoint / Power Automate', status: 'abgeschlossen', isFavorite: false, createdAt: '2024-06-01', updatedAt: d(30) },
28
- { id: '11', name: 'Customer Loyalty App', client: 'Retail Chain GmbH', application: 'React Native', status: 'neu', isFavorite: false, createdAt: '2025-03-20', updatedAt: d(0) },
29
- { id: '12', name: 'Predictive Maintenance Engine', client: 'IndustrieWerk KG', application: 'Python / FastAPI / ML',status: 'in-prufung', isFavorite: true, createdAt: '2024-09-17', updatedAt: d(4) },
30
- { id: '13', name: 'Compliance Reporting Suite', client: 'RegulaCorp AG', application: 'Power BI / Azure', status: 'offen', isFavorite: false, createdAt: '2025-02-25', updatedAt: d(11) },
31
- { id: '14', name: 'Field Service Management', client: 'TechService GmbH', application: 'Salesforce Field Srv', status: 'validierung', isFavorite: true, createdAt: '2024-07-30', updatedAt: d(6) },
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: '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',
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
- {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
- )
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-2 border-b border-border">
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-muted-foreground">
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 key={field} className="shrink-0 text-xs font-medium text-muted-foreground">
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-muted-foreground">
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
- {/* Single slim row: Fav | Title block | Badges | Expand */}
194
- <div className="flex items-center gap-3 px-4 py-2 hover:bg-accent transition-colors">
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 to fill space — no fixed width */}
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
- {/* Secondary line: subtitle + meta chips inline */}
209
- {(subtitleValue != null || layout.metaFields.length > 0) && (
210
- <div className="flex items-center gap-1.5 mt-0.5 min-w-0">
211
- {subtitleValue != null && (
212
- <span className="text-xs text-muted-foreground truncate shrink">
213
- {String(subtitleValue)}
214
- </span>
215
- )}
216
- {layout.metaFields.map((field) => {
217
- const v = row[field]
218
- if (v == null || v === '') return null
219
- return (
220
- <span
221
- key={field}
222
- className="shrink-0 bg-secondary rounded-md px-1.5 py-px text-xs truncate max-w-[140px]"
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
- {/* Badgesshrink-0, sizes to content, no fixed column */}
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
- <React.Fragment key={field}>
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
- </React.Fragment>
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="ghost"
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-semibold text-[13px] line-clamp-2', ec.bold && 'font-bold')}>
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: 'bg-[var(--lifecycle-active-bg)] text-[var(--lifecycle-active)] border-transparent',
115
- nrnd: 'bg-[var(--lifecycle-nrnd-bg)] text-[var(--lifecycle-nrnd)] border-transparent',
116
- eol: 'bg-[var(--lifecycle-eol-bg)] text-[var(--lifecycle-eol)] border-transparent',
117
- production: 'bg-[var(--lifecycle-production-bg)] text-[var(--lifecycle-production)] border-transparent',
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={cn('gap-1.5', STATUS_COLORS[status])}>
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: 'Produktkategorie', type: 'text', sortable: true, filterable: true, primaryFilter: true, visible: true, searchable: true },
7
- product_group: { label: 'Produktgruppe', type: 'text', sortable: true, filterable: true, primaryFilter: true, visible: true, searchable: true },
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', bold: true, mapTo: 'part_number', secondaryKey: 'alt_product_family_name' },
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', bold: true, mapTo: 'part_number', secondaryKey: 'cross_sell_product_family_name' },
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