@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.
- package/dist/examples/dashboard/DashboardPage/DashboardPage.tsx +1 -1
- package/dist/examples/dashboard/ProjectList/ProjectList.tsx +87 -47
- package/dist/examples/dashboard/constants.ts +20 -6
- package/dist/examples/dashboard/types.ts +2 -0
- package/dist/examples/data-visualization/CellRenderers/CellRenderers.tsx +5 -9
- package/dist/examples/data-visualization/DataCardView/DataCardView.tsx +7 -7
- package/dist/examples/data-visualization/DataListView/DataListView.tsx +83 -46
- package/dist/examples/data-visualization/DataTableView/DataTableView.tsx +8 -2
- package/dist/examples/data-visualization/DataVisualizationPage/DataVisualizationPage.tsx +12 -1
- package/dist/examples/data-visualization/constants.ts +3 -3
- package/dist/examples/data-visualization/hooks/use-data-explorer-state.ts +6 -1
- package/dist/examples/data-visualization/types.ts +11 -0
- package/dist/fastnd-components.js +4 -4
- package/package.json +1 -1
|
@@ -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-[
|
|
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 {
|
|
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
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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',
|
|
13
|
-
{ id: '2', name: 'CRM Integration MVP',
|
|
14
|
-
{ id: '3', name: 'Data Analytics Dashboard', client: 'DataViz AG', application: 'React / Supabase',
|
|
15
|
-
{ id: '4', name: 'Mobile App Onboarding',
|
|
16
|
-
{ id: '5', name: 'Payment Gateway Update',
|
|
17
|
-
{ id: '6', name: 'Legacy System Migration',
|
|
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
|
]
|
|
@@ -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
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
155
|
-
className="
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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="
|
|
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=
|
|
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,
|
|
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 {
|