@fastnd/components 1.0.32 → 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.
@@ -25,7 +25,7 @@ const DashboardPage = React.forwardRef<HTMLElement, DashboardPageProps>(
25
25
  ref={ref}
26
26
  data-slot="dashboard-page"
27
27
  className={cn(
28
- 'grid grid-cols-1 lg:grid-cols-[350px_1fr] gap-6 max-w-[1400px] mx-auto p-8',
28
+ 'grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-6 max-w-[1400px] mx-auto p-8',
29
29
  className,
30
30
  )}
31
31
  {...props}
@@ -1,11 +1,11 @@
1
1
  import * as React from 'react'
2
- import { Star } from 'lucide-react'
3
2
  import { Card, CardContent } from '@/components/ui/card'
4
3
  import { Input } from '@/components/ui/input'
5
- import { Table, TableHeader, TableHead, TableBody, TableRow, TableCell } from '@/components/ui/table'
6
4
  import { Badge } from '@/components/ui/badge'
7
- import { FavoriteButton } from '@/components/FavoriteButton/FavoriteButton'
5
+ import { DataTableView } from '@/features/data-visualization/DataTableView/DataTableView'
6
+ import { formatRelativeDate } from '@/utils/date'
8
7
  import { cn } from '@/lib/utils'
8
+ import type { ColumnDef, SortState } from '@/features/data-visualization/types'
9
9
  import type { Project, ProjectStatus } from '../types'
10
10
  import { STATUS_CONFIG } from '../constants'
11
11
 
@@ -17,6 +17,9 @@ const STATUS_BADGE_CLASSES: Record<ProjectStatus, string> = {
17
17
  'abgeschlossen': 'bg-[#1ec489]/15 text-[#0a6e44] border-transparent',
18
18
  }
19
19
 
20
+ const STATIC_SORT: SortState = { column: null, direction: 'asc' }
21
+ const EMPTY_ROWS = new Set<string>()
22
+
20
23
  interface ProjectListProps extends React.ComponentProps<'section'> {
21
24
  projects: Project[]
22
25
  filterLabel: string
@@ -38,6 +41,75 @@ const ProjectList = React.forwardRef<HTMLElement, ProjectListProps>(
38
41
  },
39
42
  ref,
40
43
  ) => {
44
+ const columns: Record<string, ColumnDef> = React.useMemo(() => ({
45
+ isFavorite: {
46
+ label: 'Favorit',
47
+ type: 'favorite',
48
+ sortable: false,
49
+ filterable: false,
50
+ visible: true,
51
+ headerIcon: 'star',
52
+ headerTooltip: 'Favorit',
53
+ },
54
+ name: {
55
+ label: 'Projekt / Applikation',
56
+ type: 'double-text',
57
+ secondary: 'application',
58
+ sortable: false,
59
+ filterable: false,
60
+ visible: true,
61
+ },
62
+ client: {
63
+ label: 'Kunde',
64
+ type: 'text',
65
+ sortable: false,
66
+ filterable: false,
67
+ visible: true,
68
+ },
69
+ status: {
70
+ label: 'Status',
71
+ type: 'text',
72
+ sortable: false,
73
+ filterable: false,
74
+ visible: true,
75
+ render: (val) => {
76
+ const s = val as ProjectStatus
77
+ return (
78
+ <Badge variant="secondary" className={STATUS_BADGE_CLASSES[s]}>
79
+ {STATUS_CONFIG[s].label}
80
+ </Badge>
81
+ )
82
+ },
83
+ },
84
+ updatedAt: {
85
+ label: 'Aktivität',
86
+ type: 'text',
87
+ sortable: false,
88
+ filterable: false,
89
+ visible: true,
90
+ render: (val, row) => (
91
+ <div className="flex flex-col">
92
+ <span className="font-medium text-[13px]">{formatRelativeDate(String(val))}</span>
93
+ <span className="text-muted-foreground text-xs">
94
+ Erstellt: {new Date(String(row.createdAt)).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
95
+ </span>
96
+ </div>
97
+ ),
98
+ },
99
+ }), [])
100
+
101
+ const visibleColumns = ['isFavorite', 'name', 'client', 'status', 'updatedAt']
102
+
103
+ const rowData = React.useMemo(
104
+ () => projects.map(p => ({ ...p } as Record<string, unknown>)),
105
+ [projects],
106
+ )
107
+
108
+ const favorites = React.useMemo(
109
+ () => new Set(projects.filter(p => p.isFavorite).map(p => p.id)),
110
+ [projects],
111
+ )
112
+
41
113
  return (
42
114
  <section
43
115
  ref={ref}
@@ -63,50 +135,18 @@ const ProjectList = React.forwardRef<HTMLElement, ProjectListProps>(
63
135
  onChange={e => onSearchChange(e.target.value)}
64
136
  />
65
137
  </div>
66
- <CardContent>
67
- <Table>
68
- <TableHeader>
69
- <TableRow>
70
- <TableHead scope="col">Projektname</TableHead>
71
- <TableHead scope="col">Kunde</TableHead>
72
- <TableHead scope="col">Applikation</TableHead>
73
- <TableHead scope="col">Status</TableHead>
74
- <TableHead scope="col" aria-label="Favorit">
75
- <Star className="size-3.5" />
76
- </TableHead>
77
- </TableRow>
78
- </TableHeader>
79
- <TableBody>
80
- {projects.map(project => (
81
- <TableRow key={project.id}>
82
- <TableCell>
83
- <span className="font-medium">{project.name}</span>
84
- </TableCell>
85
- <TableCell>
86
- <span className="text-muted-foreground text-[0.8125rem]">
87
- {project.client}
88
- </span>
89
- </TableCell>
90
- <TableCell>{project.application}</TableCell>
91
- <TableCell>
92
- <Badge
93
- variant="secondary"
94
- className={cn(STATUS_BADGE_CLASSES[project.status])}
95
- >
96
- {STATUS_CONFIG[project.status].label}
97
- </Badge>
98
- </TableCell>
99
- <TableCell>
100
- <FavoriteButton
101
- pressed={project.isFavorite}
102
- projectName={project.name}
103
- onPressedChange={() => onToggleFavorite(project.id)}
104
- />
105
- </TableCell>
106
- </TableRow>
107
- ))}
108
- </TableBody>
109
- </Table>
138
+ <CardContent className="p-0">
139
+ <DataTableView
140
+ data={rowData}
141
+ columns={columns}
142
+ visibleColumns={visibleColumns}
143
+ sort={STATIC_SORT}
144
+ onToggleSort={() => {}}
145
+ expandedRows={EMPTY_ROWS}
146
+ onToggleExpansion={() => {}}
147
+ favorites={favorites}
148
+ onToggleFavorite={onToggleFavorite}
149
+ />
110
150
  </CardContent>
111
151
  </Card>
112
152
  </section>
@@ -8,11 +8,25 @@ export const STATUS_CONFIG: Record<ProjectStatus, { label: string; color: string
8
8
  'abgeschlossen': { label: 'Abgeschlossen', color: '#1ec489' },
9
9
  }
10
10
 
11
+ const d = (daysAgo: number) => new Date(Date.now() - daysAgo * 86_400_000).toISOString()
12
+
11
13
  export const MOCK_PROJECTS: Project[] = [
12
- { id: '1', name: 'E-Commerce Relaunch', client: 'Müller GmbH', application: 'Shopify CMS', status: 'neu', isFavorite: false },
13
- { id: '2', name: 'CRM Integration MVP', client: 'TechCorp Inc.', application: 'Salesforce API', status: 'offen', isFavorite: true },
14
- { id: '3', name: 'Data Analytics Dashboard', client: 'DataViz AG', application: 'React / Supabase', status: 'in-prufung', isFavorite: false },
15
- { id: '4', name: 'Mobile App Onboarding', client: 'FitHealth', application: 'Flutter', status: 'neu', isFavorite: false },
16
- { id: '5', name: 'Payment Gateway Update', client: 'FinTech Solutions', application: 'Stripe API', status: 'validierung', isFavorite: false },
17
- { id: '6', name: 'Legacy System Migration', client: 'OldBank Corp.', application: 'Node.js / PostgreSQL', status: 'abgeschlossen', isFavorite: true },
14
+ { id: '1', name: 'E-Commerce Relaunch', client: 'Müller GmbH', application: 'Shopify CMS', status: 'neu', isFavorite: false, createdAt: '2025-03-15', updatedAt: d(0) },
15
+ { id: '2', name: 'CRM Integration MVP', client: 'TechCorp Inc.', application: 'Salesforce API', status: 'offen', isFavorite: true, createdAt: '2025-02-10', updatedAt: d(1) },
16
+ { id: '3', name: 'Data Analytics Dashboard', client: 'DataViz AG', application: 'React / Supabase', status: 'in-prufung', isFavorite: false, createdAt: '2025-01-20', updatedAt: d(3) },
17
+ { id: '4', name: 'Mobile App Onboarding', client: 'FitHealth', application: 'Flutter', status: 'neu', isFavorite: false, createdAt: '2025-03-01', updatedAt: d(7) },
18
+ { id: '5', name: 'Payment Gateway Update', client: 'FinTech Solutions', application: 'Stripe API', status: 'validierung', isFavorite: false, createdAt: '2024-11-05', updatedAt: d(14) },
19
+ { id: '6', name: 'Legacy System Migration', client: 'OldBank Corp.', application: 'Node.js / PostgreSQL', status: 'abgeschlossen', isFavorite: true, createdAt: '2024-08-20', updatedAt: d(60) },
20
+ ]
21
+
22
+ export const MOCK_PROJECTS_EXTENDED: Project[] = [
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) },
18
32
  ]
@@ -9,6 +9,8 @@ export interface Project {
9
9
  application: string
10
10
  status: ProjectStatus
11
11
  isFavorite: boolean
12
+ createdAt: string
13
+ updatedAt: string
12
14
  }
13
15
 
14
16
  export interface StatusCount {
@@ -5,15 +5,9 @@ import { Button } from '@/components/ui/button'
5
5
  import { ScoreBar } from '@/components/ScoreBar/ScoreBar'
6
6
  import { FavoriteButton } from '@/components/FavoriteButton/FavoriteButton'
7
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
- }
8
+ import type { ColumnDef, RenderCellOptions } from '../types'
9
+
10
+ export type { RenderCellOptions }
17
11
 
18
12
  const STATUS_COLORS: Record<string, string> = {
19
13
  active: 'bg-[var(--lifecycle-active-bg)] text-[var(--lifecycle-active)] border-transparent',
@@ -42,6 +36,8 @@ export function renderCell(
42
36
  const { mode = 'default', isExpanded = false, isFavorite = false, onToggleExpand, onToggleFavorite } = options
43
37
  const val = row[colKey]
44
38
 
39
+ if (col.render) return col.render(val, row, options)
40
+
45
41
  switch (col.type) {
46
42
  case 'text': {
47
43
  // col.rowLines drives the clamp; default 2 so columns can shrink without growing rows
@@ -97,7 +97,7 @@ function DataCardView({
97
97
  <div
98
98
  ref={gridRef}
99
99
  data-slot="data-card-view"
100
- className={cn('grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-3', className)}
100
+ className={cn('grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4', className)}
101
101
  >
102
102
  {data.map((row) => {
103
103
  const rowId = String(row.id ?? '')
@@ -111,7 +111,7 @@ function DataCardView({
111
111
  <div className="flex flex-col">
112
112
  <div
113
113
  className={cn(
114
- 'flex flex-col border border-border rounded-lg overflow-hidden hover:shadow-md transition-shadow bg-card text-card-foreground',
114
+ 'flex flex-col flex-1 border border-border rounded-lg overflow-hidden hover:shadow-md transition-shadow bg-card text-card-foreground',
115
115
  isAnyExpanded && 'border-b-2 border-b-primary',
116
116
  )}
117
117
  >
@@ -136,7 +136,7 @@ function DataCardView({
136
136
 
137
137
  {/* Badges */}
138
138
  {layout.badgeFields.length > 0 && (
139
- <div className="px-4 pt-2 flex gap-2 flex-wrap">
139
+ <div className="px-4 pt-2 flex gap-1.5 flex-wrap">
140
140
  {layout.badgeFields.map((bf) => {
141
141
  const col = columns[bf]
142
142
  if (!col || row[bf] == null) return null
@@ -163,9 +163,9 @@ function DataCardView({
163
163
  )
164
164
  : String(row[r.field] ?? '')
165
165
  return (
166
- <div key={r.field} className="flex justify-between items-center">
167
- <span className="text-xs text-muted-foreground">{r.label}</span>
168
- <span className="text-[13px] font-medium">{valueNode}</span>
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
169
  </div>
170
170
  )
171
171
  })}
@@ -174,7 +174,7 @@ function DataCardView({
174
174
 
175
175
  {/* Footer — expand buttons */}
176
176
  {expandFields.length > 0 && (
177
- <div className="p-4 pt-3">
177
+ <div className="mt-auto p-4 pt-3 border-t border-border">
178
178
  <div className="flex gap-2">
179
179
  {expandFields.map((ef) => {
180
180
  const col = columns[ef]
@@ -23,6 +23,7 @@ export interface DataListViewProps {
23
23
  onToggleExpansion: (rowId: string, field: string) => void
24
24
  favorites: Set<string>
25
25
  onToggleFavorite: (id: string) => void
26
+ showHeader?: boolean
26
27
  className?: string
27
28
  }
28
29
 
@@ -122,6 +123,43 @@ function ExpansionCell({ ec, value }: ExpansionCellProps) {
122
123
  return <span>{text}</span>
123
124
  }
124
125
 
126
+ interface ListHeaderProps {
127
+ columns: Record<string, ColumnDef>
128
+ layout: ListLayout
129
+ }
130
+
131
+ function ListHeader({ columns, layout }: ListHeaderProps) {
132
+ const titleCol = columns[layout.titleField]
133
+ const expandFields = layout.expandFields ?? (layout.expandField ? [layout.expandField] : [])
134
+ const secondaryLabel =
135
+ titleCol?.type === 'double-text' && titleCol.secondary
136
+ ? columns[titleCol.secondary]?.label
137
+ : undefined
138
+ const titleLabel = secondaryLabel
139
+ ? `${titleCol!.label} / ${secondaryLabel}`
140
+ : (titleCol?.label ?? '')
141
+
142
+ return (
143
+ <div className="flex items-center gap-3 px-4 py-2 border-b border-border">
144
+ {/* Placeholder matching FavoriteButton size */}
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">
147
+ {titleLabel}
148
+ </div>
149
+ {layout.badgeFields.map((field) => (
150
+ <span key={field} className="shrink-0 text-xs font-medium text-muted-foreground">
151
+ {columns[field]?.label ?? ''}
152
+ </span>
153
+ ))}
154
+ {expandFields.map((field) => (
155
+ <span key={field} className="shrink-0 text-xs font-medium text-muted-foreground">
156
+ {columns[field]?.expandLabel ?? columns[field]?.label ?? ''}
157
+ </span>
158
+ ))}
159
+ </div>
160
+ )
161
+ }
162
+
125
163
  function DataListView({
126
164
  data,
127
165
  columns,
@@ -130,6 +168,7 @@ function DataListView({
130
168
  onToggleExpansion,
131
169
  favorites,
132
170
  onToggleFavorite,
171
+ showHeader = false,
133
172
  className,
134
173
  }: DataListViewProps) {
135
174
  const expandFields = layout.expandFields ?? (layout.expandField ? [layout.expandField] : [])
@@ -139,58 +178,58 @@ function DataListView({
139
178
  return (
140
179
  <div
141
180
  data-slot="data-list-view"
142
- className={cn(
143
- 'border border-border rounded-lg overflow-hidden divide-y divide-border',
144
- className,
145
- )}
181
+ className={cn('divide-y divide-border', className)}
146
182
  >
183
+ {showHeader && <ListHeader columns={columns} layout={layout} />}
147
184
  {data.map((row) => {
148
185
  const rowId = String(row.id ?? '')
149
186
  const titleValue = row[layout.titleField]
150
187
  const subtitleValue = isDoubleText && titleCol.secondary ? row[titleCol.secondary] : undefined
188
+ const hasBadges = layout.badgeFields.length > 0
189
+ const hasExpand = expandFields.length > 0
151
190
 
152
191
  return (
153
- <div
154
- key={rowId}
155
- className="grid grid-cols-[36px_1fr_auto_auto] sm:grid-cols-[36px_1fr_auto_auto] items-start gap-x-3 px-4 py-3 hover:bg-accent transition-colors"
156
- >
157
- {/* Col 1: Favorite */}
158
- <div className="flex items-center justify-center pt-0.5">
159
- <FavoriteButton
160
- pressed={favorites.has(rowId)}
161
- itemName={titleValue != null ? String(titleValue) : ''}
162
- onPressedChange={() => onToggleFavorite(rowId)}
163
- />
164
- </div>
165
-
166
- {/* Col 2: Title block */}
167
- <div className="min-w-0">
168
- <div className="font-semibold text-sm truncate max-w-[280px] sm:max-w-none">
169
- {titleValue != null ? String(titleValue) : ''}
192
+ <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">
196
+ <FavoriteButton
197
+ pressed={favorites.has(rowId)}
198
+ itemName={titleValue != null ? String(titleValue) : ''}
199
+ onPressedChange={() => onToggleFavorite(rowId)}
200
+ />
170
201
  </div>
171
- <div className="flex flex-wrap items-center gap-2 mt-0.5">
172
- {subtitleValue != null && (
173
- <span className="text-xs text-muted-foreground truncate max-w-[320px] sm:max-w-none">
174
- {String(subtitleValue)}
175
- </span>
202
+
203
+ {/* Title block grows to fill space — no fixed width */}
204
+ <div className="flex-1 min-w-0">
205
+ <div className="font-semibold text-sm truncate">
206
+ {titleValue != null ? String(titleValue) : ''}
207
+ </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>
176
229
  )}
177
- {layout.metaFields.map((field) => {
178
- const v = row[field]
179
- if (v == null || v === '') return null
180
- return (
181
- <span
182
- key={field}
183
- className="shrink-0 bg-secondary rounded-md px-2 py-0.5 text-xs max-w-[180px] truncate"
184
- >
185
- {String(v)}
186
- </span>
187
- )
188
- })}
189
230
  </div>
190
- </div>
191
231
 
192
- {/* Col 3: Badges */}
193
- <div className="flex items-center gap-2 pt-0.5 max-sm:col-start-2 max-sm:col-end-3 max-sm:row-start-2">
232
+ {/* Badges shrink-0, sizes to content, no fixed column */}
194
233
  {layout.badgeFields.map((field) => {
195
234
  const col = columns[field]
196
235
  if (!col) return null
@@ -202,10 +241,8 @@ function DataListView({
202
241
  </React.Fragment>
203
242
  )
204
243
  })}
205
- </div>
206
244
 
207
- {/* Col 4: Expand buttons */}
208
- <div className="flex items-center gap-2 pt-0.5 max-sm:col-start-2 max-sm:col-end-3 max-sm:row-start-3">
245
+ {/* Expand buttons — trailing edge */}
209
246
  {expandFields.map((field) => {
210
247
  const col = columns[field]
211
248
  if (!col) return null
@@ -222,14 +259,14 @@ function DataListView({
222
259
  })}
223
260
  </div>
224
261
 
225
- {/* Expansion content — full width */}
262
+ {/* Expansion panels — full width, below the row */}
226
263
  {expandFields.map((field) => {
227
264
  const expandKey = `${rowId}::${field}`
228
265
  if (!expandedRows.has(expandKey)) return null
229
266
  return (
230
267
  <div
231
268
  key={field}
232
- className="col-span-full border-t border-border pt-3 mt-1 max-sm:col-start-1 max-sm:row-start-4"
269
+ className="px-4 pb-3 pt-3 border-t border-border"
233
270
  data-slot="expansion-content"
234
271
  >
235
272
  <ExpansionTable row={row} colKey={field} columns={columns} />
@@ -1,5 +1,5 @@
1
1
  import React, { useState, useRef, useCallback } from 'react'
2
- import { ChevronUp, ChevronDown, ArrowLeftRight, Sparkles, Plus } from 'lucide-react'
2
+ import { ChevronUp, ChevronDown, ArrowLeftRight, Sparkles, Plus, Star } from 'lucide-react'
3
3
  import {
4
4
  Table,
5
5
  TableBody,
@@ -24,6 +24,7 @@ import type { ColumnDef, ExpandColumnDef, SortState } from '../types'
24
24
  const HEADER_ICONS: Record<string, React.ElementType> = {
25
25
  arrowLeftRight: ArrowLeftRight,
26
26
  sparkles: Sparkles,
27
+ star: Star,
27
28
  }
28
29
 
29
30
  // Default minimum widths per column type (px)
@@ -227,6 +228,7 @@ export interface DataTableViewProps {
227
228
  onToggleExpansion: (rowId: string, field: string) => void
228
229
  favorites: Set<string>
229
230
  onToggleFavorite: (id: string) => void
231
+ footer?: React.ReactNode
230
232
  className?: string
231
233
  }
232
234
 
@@ -240,6 +242,7 @@ export function DataTableView({
240
242
  onToggleExpansion,
241
243
  favorites,
242
244
  onToggleFavorite,
245
+ footer,
243
246
  className,
244
247
  }: DataTableViewProps) {
245
248
  // Column widths: keyed by column key, value in px
@@ -286,8 +289,9 @@ export function DataTableView({
286
289
  const expandColKeys = visibleColumns.filter((k) => columns[k]?.type === 'expand')
287
290
 
288
291
  return (
292
+ <div className={cn('mx-4 mb-4 border border-border rounded-md overflow-hidden', className)}>
289
293
  <div
290
- className={cn('overflow-x-auto', className)}
294
+ className="overflow-x-auto"
291
295
  role="region"
292
296
  aria-label="Datentabelle"
293
297
  tabIndex={0}
@@ -445,5 +449,7 @@ export function DataTableView({
445
449
  </TableBody>
446
450
  </Table>
447
451
  </div>
452
+ {footer && <div className="border-t border-border">{footer}</div>}
453
+ </div>
448
454
  )
449
455
  }
@@ -97,6 +97,17 @@ export function DataVisualizationPage({ className }: { className?: string }) {
97
97
  onToggleExpansion={state.toggleExpansion}
98
98
  favorites={state.favorites}
99
99
  onToggleFavorite={state.toggleFavorite}
100
+ footer={
101
+ <DataExplorerPagination
102
+ currentPage={state.currentPage}
103
+ totalPages={state.totalPages}
104
+ pageSize={state.pageSize}
105
+ totalFiltered={state.totalFiltered}
106
+ resultLabel={state.domainConfig.resultLabel}
107
+ onPageChange={state.setPage}
108
+ onPageSizeChange={state.setPageSize}
109
+ />
110
+ }
100
111
  />
101
112
  )}
102
113
  {state.viewMode === 'list' && (
@@ -124,7 +135,7 @@ export function DataVisualizationPage({ className }: { className?: string }) {
124
135
  </>
125
136
  )}
126
137
 
127
- {state.totalFiltered > 0 && (
138
+ {state.totalFiltered > 0 && state.viewMode !== 'table' && (
128
139
  <DataExplorerPagination
129
140
  currentPage={state.currentPage}
130
141
  totalPages={state.totalPages}
@@ -283,7 +283,7 @@ export const MOCK_PRODUCTS: Record<string, unknown>[] = [
283
283
  // ===== PROJECT DOMAIN =====
284
284
 
285
285
  const PROJECT_COLUMNS: Record<string, ColumnDef> = {
286
- favorite: { label: 'Favorit', type: 'favorite', sortable: false, filterable: false, visible: true },
286
+ favorite: { label: 'Favorit', type: 'favorite', sortable: false, filterable: false, visible: true, headerIcon: 'star', headerTooltip: 'Favorit' },
287
287
  name: { label: 'Projektname', type: 'double-text', sortable: true, filterable: false, visible: true, secondary: 'customer_name', searchable: true },
288
288
  customer_name: { label: 'Kunde', type: 'link', sortable: true, filterable: true, primaryFilter: true, visible: true, searchable: true },
289
289
  status: {
@@ -354,7 +354,7 @@ export const MOCK_PROJECTS: Record<string, unknown>[] = [
354
354
  // ===== CUSTOMER DOMAIN =====
355
355
 
356
356
  const CUSTOMER_COLUMNS: Record<string, ColumnDef> = {
357
- favorite: { label: 'Favorit', type: 'favorite', sortable: false, filterable: false, visible: true },
357
+ favorite: { label: 'Favorit', type: 'favorite', sortable: false, filterable: false, visible: true, headerIcon: 'star', headerTooltip: 'Favorit' },
358
358
  name: { label: 'Kunde', type: 'double-text', sortable: true, filterable: false, visible: true, secondary: 'main_customer', searchable: true },
359
359
  category: { label: 'Kategorie', type: 'text', sortable: true, filterable: true, primaryFilter: true, visible: true, searchable: true },
360
360
  region: { label: 'Region', type: 'text', sortable: true, filterable: true, primaryFilter: true, visible: true, hideTablet: true },
@@ -418,7 +418,7 @@ export const MOCK_CUSTOMERS: Record<string, unknown>[] = [
418
418
  // ===== APPLICATION DOMAIN =====
419
419
 
420
420
  const APPLICATION_COLUMNS: Record<string, ColumnDef> = {
421
- favorite: { label: 'Favorit', type: 'favorite', sortable: false, filterable: false, visible: true },
421
+ favorite: { label: 'Favorit', type: 'favorite', sortable: false, filterable: false, visible: true, headerIcon: 'star', headerTooltip: 'Favorit' },
422
422
  name: { label: 'Applikation', type: 'double-text', sortable: true, filterable: false, visible: true, secondary: 'description', searchable: true },
423
423
  url: { label: 'Referenz', type: 'link', sortable: false, filterable: false, visible: true, hideMobile: true },
424
424
  trends: { label: 'Trend', type: 'text', sortable: true, filterable: true, primaryFilter: true, visible: true, hideTablet: true, searchable: true },
@@ -63,7 +63,7 @@ export function useDataExplorerState(
63
63
  initialDomain: DomainKey = 'products',
64
64
  ): DataExplorerState {
65
65
  const [activeDomain, setActiveDomainRaw] = useState<DomainKey>(initialDomain)
66
- const [viewMode, setViewMode] = useState<ViewMode>('table')
66
+ const [viewMode, setViewModeRaw] = useState<ViewMode>('table')
67
67
  const [sort, setSort] = useState<SortState>({ column: null, direction: 'asc' })
68
68
  const [filters, setFilters] = useState<FilterState>({})
69
69
  const [searchTerm, setSearchTermRaw] = useState('')
@@ -79,6 +79,11 @@ export function useDataExplorerState(
79
79
  [activeDomain],
80
80
  )
81
81
 
82
+ const setViewMode = useCallback((mode: ViewMode) => {
83
+ setViewModeRaw(mode)
84
+ setSort({ column: null, direction: 'asc' })
85
+ }, [])
86
+
82
87
  const setActiveDomain = useCallback((domain: DomainKey) => {
83
88
  setActiveDomainRaw(domain)
84
89
  setSort({ column: null, direction: 'asc' })
@@ -1,3 +1,5 @@
1
+ import type React from 'react'
2
+
1
3
  export type DomainKey = 'products' | 'projects' | 'customers' | 'applications'
2
4
 
3
5
  export type CellType =
@@ -32,6 +34,14 @@ export interface ExpandColumnDef {
32
34
  type?: CellType
33
35
  }
34
36
 
37
+ export interface RenderCellOptions {
38
+ mode?: 'default' | 'compact' | 'inventory-label'
39
+ isExpanded?: boolean
40
+ isFavorite?: boolean
41
+ onToggleExpand?: () => void
42
+ onToggleFavorite?: () => void
43
+ }
44
+
35
45
  export interface ColumnDef {
36
46
  label: string
37
47
  type: CellType
@@ -57,6 +67,7 @@ export interface ColumnDef {
57
67
  hideTablet?: boolean
58
68
  hideMobile?: boolean
59
69
  rowLines?: number
70
+ render?: (val: unknown, row: Record<string, unknown>, opts: RenderCellOptions) => React.ReactNode
60
71
  }
61
72
 
62
73
  export interface ListLayout {