@fastnd/components 1.0.27 → 1.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/ColumnConfigPopover/ColumnConfigPopover.d.ts +20 -0
- package/dist/components/DoubleTextCell/DoubleTextCell.d.ts +9 -0
- package/dist/components/ProgressCircle/ProgressCircle.d.ts +9 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/ui/badge.d.ts +1 -1
- package/dist/components/ui/button.d.ts +1 -1
- package/dist/components/ui/input-group.d.ts +1 -1
- package/dist/components/ui/item.d.ts +1 -1
- package/dist/components/ui/tabs.d.ts +1 -1
- package/dist/examples/data-visualization/DataGrid/DataGrid.tsx +136 -0
- package/dist/examples/data-visualization/DataGridCardView/DataGridCardView.tsx +179 -0
- package/dist/examples/data-visualization/DataGridListView/DataGridListView.tsx +190 -0
- package/dist/examples/data-visualization/DataGridPage/DataGridPage.tsx +43 -0
- package/dist/examples/data-visualization/DataGridPagination/DataGridPagination.tsx +111 -0
- package/dist/examples/data-visualization/DataGridTableView/DataGridTableView.tsx +282 -0
- package/dist/examples/data-visualization/DataGridToolbar/DataGridToolbar.tsx +283 -0
- package/dist/examples/data-visualization/DomainSwitcher/DomainSwitcher.tsx +41 -0
- package/dist/examples/data-visualization/ExpansionDrawer/ExpansionDrawer.tsx +139 -0
- package/dist/examples/data-visualization/MoreFiltersPopover/MoreFiltersPopover.tsx +230 -0
- package/dist/examples/data-visualization/ResultCount/ResultCount.tsx +33 -0
- package/dist/examples/data-visualization/cell-renderers.tsx +119 -0
- package/dist/examples/data-visualization/constants.ts +1251 -0
- package/dist/examples/data-visualization/hooks/use-data-grid-columns.ts +65 -0
- package/dist/examples/data-visualization/hooks/use-data-grid-expansion.ts +40 -0
- package/dist/examples/data-visualization/hooks/use-data-grid-favorites.ts +41 -0
- package/dist/examples/data-visualization/hooks/use-data-grid-filters.ts +61 -0
- package/dist/examples/data-visualization/hooks/use-data-grid-pagination.ts +32 -0
- package/dist/examples/data-visualization/hooks/use-data-grid-sort.ts +32 -0
- package/dist/examples/data-visualization/hooks/use-data-grid-state.ts +133 -0
- package/dist/examples/data-visualization/hooks/use-filtered-data.ts +84 -0
- package/dist/examples/data-visualization/index.ts +10 -0
- package/dist/examples/data-visualization/types.ts +103 -0
- package/dist/fastnd-components.js +18759 -15519
- package/package.json +1 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
|
|
5
|
+
interface DomainSwitcherProps extends React.ComponentProps<'div'> {
|
|
6
|
+
domains: Array<{ key: string; label: string }>
|
|
7
|
+
activeDomain: string
|
|
8
|
+
onDomainChange: (key: string) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DomainSwitcher = React.forwardRef<HTMLDivElement, DomainSwitcherProps>(
|
|
12
|
+
({ domains, activeDomain, onDomainChange, className, ...props }, ref) => {
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
ref={ref}
|
|
16
|
+
data-slot="domain-switcher"
|
|
17
|
+
aria-label="Datenquelle wechseln"
|
|
18
|
+
className={cn('flex items-center gap-3', className)}
|
|
19
|
+
{...props}
|
|
20
|
+
>
|
|
21
|
+
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
22
|
+
Datenquelle
|
|
23
|
+
</span>
|
|
24
|
+
<Tabs value={activeDomain} onValueChange={onDomainChange}>
|
|
25
|
+
<TabsList>
|
|
26
|
+
{domains.map((domain) => (
|
|
27
|
+
<TabsTrigger key={domain.key} value={domain.key}>
|
|
28
|
+
{domain.label}
|
|
29
|
+
</TabsTrigger>
|
|
30
|
+
))}
|
|
31
|
+
</TabsList>
|
|
32
|
+
</Tabs>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
DomainSwitcher.displayName = 'DomainSwitcher'
|
|
39
|
+
|
|
40
|
+
export { DomainSwitcher }
|
|
41
|
+
export type { DomainSwitcherProps }
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Plus } from 'lucide-react'
|
|
3
|
+
import { Badge } from '@/components/ui/badge'
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
import type { ExpandColumnConfig } from '../types'
|
|
7
|
+
|
|
8
|
+
interface ExpansionDrawerProps extends React.ComponentProps<'tr'> {
|
|
9
|
+
items: Record<string, unknown>[]
|
|
10
|
+
expandColumns: ExpandColumnConfig[]
|
|
11
|
+
title: string
|
|
12
|
+
colSpan: number
|
|
13
|
+
isOpen: boolean
|
|
14
|
+
onAdd?: (item: Record<string, unknown>) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getScoreBarColor(pct: number): string {
|
|
18
|
+
const hue = (pct / 100) * 142
|
|
19
|
+
return `hsl(${hue}, 75%, 45%)`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function ScoreBar({ value }: { value: unknown }) {
|
|
23
|
+
const raw = typeof value === 'number' ? value : Number(value)
|
|
24
|
+
const pct = raw <= 1 ? raw * 100 : raw
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex items-center gap-2">
|
|
28
|
+
<div className="relative h-2 flex-1 overflow-hidden rounded-full bg-muted">
|
|
29
|
+
<div
|
|
30
|
+
className="h-full rounded-full"
|
|
31
|
+
style={{ width: `${pct}%`, backgroundColor: getScoreBarColor(pct) }}
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
<span className="text-xs font-medium tabular-nums w-8 text-right">{pct.toFixed(0)}%</span>
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function CellContent({ col, value }: { col: ExpandColumnConfig; value: unknown }) {
|
|
40
|
+
if (col.type === 'score-bar') {
|
|
41
|
+
return (
|
|
42
|
+
<td className="p-2 align-middle">
|
|
43
|
+
<ScoreBar value={value} />
|
|
44
|
+
</td>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (col.bold) {
|
|
49
|
+
return (
|
|
50
|
+
<td className="p-2 align-middle">
|
|
51
|
+
<span className="font-medium">{String(value ?? '')}</span>
|
|
52
|
+
</td>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (col.muted) {
|
|
57
|
+
const str = String(value ?? '')
|
|
58
|
+
if (str.includes('AI')) {
|
|
59
|
+
return (
|
|
60
|
+
<td className="p-2 align-middle">
|
|
61
|
+
<Badge
|
|
62
|
+
className="bg-[var(--color-info-variant)] text-[var(--color-info)] border-transparent text-[0.625rem] font-semibold uppercase tracking-wide mr-1.5"
|
|
63
|
+
>
|
|
64
|
+
KI
|
|
65
|
+
</Badge>
|
|
66
|
+
<span className="text-xs text-muted-foreground">{str}</span>
|
|
67
|
+
</td>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
return (
|
|
71
|
+
<td className="p-2 align-middle text-xs text-muted-foreground">{str}</td>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return <td className="p-2 align-middle">{String(value ?? '')}</td>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const ExpansionDrawer = React.forwardRef<HTMLTableRowElement, ExpansionDrawerProps>(
|
|
79
|
+
({ items, expandColumns, title, colSpan, isOpen, onAdd, className, ...props }, ref) => {
|
|
80
|
+
return (
|
|
81
|
+
<tr
|
|
82
|
+
ref={ref}
|
|
83
|
+
data-slot="expansion-drawer"
|
|
84
|
+
className={cn('border-b', !isOpen && 'hidden', className)}
|
|
85
|
+
{...props}
|
|
86
|
+
>
|
|
87
|
+
<td colSpan={colSpan} className="p-0">
|
|
88
|
+
<div className="bg-muted/30 px-8 py-4">
|
|
89
|
+
<div className="text-sm font-medium mb-3">{title}</div>
|
|
90
|
+
<table className="w-full text-sm" aria-label={title}>
|
|
91
|
+
<thead>
|
|
92
|
+
<tr>
|
|
93
|
+
{expandColumns.map((col) => (
|
|
94
|
+
<th
|
|
95
|
+
key={col.key}
|
|
96
|
+
scope="col"
|
|
97
|
+
className="p-2 text-left align-middle font-medium text-muted-foreground text-xs"
|
|
98
|
+
>
|
|
99
|
+
{col.label}
|
|
100
|
+
</th>
|
|
101
|
+
))}
|
|
102
|
+
<th scope="col" className="w-24 p-2" />
|
|
103
|
+
</tr>
|
|
104
|
+
</thead>
|
|
105
|
+
<tbody>
|
|
106
|
+
{items.map((item, index) => {
|
|
107
|
+
const firstColValue = String(item[expandColumns[0]?.key] ?? index)
|
|
108
|
+
return (
|
|
109
|
+
<tr key={index} className="border-t border-border/50">
|
|
110
|
+
{expandColumns.map((col) => (
|
|
111
|
+
<CellContent key={col.key} col={col} value={item[col.key]} />
|
|
112
|
+
))}
|
|
113
|
+
<td className="p-2 align-middle">
|
|
114
|
+
<Button
|
|
115
|
+
variant="ghost"
|
|
116
|
+
size="sm"
|
|
117
|
+
aria-label={`${firstColValue} hinzufügen`}
|
|
118
|
+
onClick={() => onAdd?.(item)}
|
|
119
|
+
>
|
|
120
|
+
<Plus size={14} />
|
|
121
|
+
Hinzufügen
|
|
122
|
+
</Button>
|
|
123
|
+
</td>
|
|
124
|
+
</tr>
|
|
125
|
+
)
|
|
126
|
+
})}
|
|
127
|
+
</tbody>
|
|
128
|
+
</table>
|
|
129
|
+
</div>
|
|
130
|
+
</td>
|
|
131
|
+
</tr>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
ExpansionDrawer.displayName = 'ExpansionDrawer'
|
|
137
|
+
|
|
138
|
+
export { ExpansionDrawer }
|
|
139
|
+
export type { ExpansionDrawerProps }
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { SlidersHorizontal, ChevronDown, Check } from 'lucide-react'
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
|
5
|
+
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'
|
|
6
|
+
import { Badge } from '@/components/ui/badge'
|
|
7
|
+
import type { ColumnConfig, DataGridFilters } from '../types'
|
|
8
|
+
|
|
9
|
+
interface MoreFiltersPopoverProps extends React.ComponentProps<'div'> {
|
|
10
|
+
columns: Record<string, ColumnConfig>
|
|
11
|
+
columnOrder: string[]
|
|
12
|
+
visibleColumns: Set<string>
|
|
13
|
+
filters: DataGridFilters
|
|
14
|
+
filterOptions: Record<string, string[]>
|
|
15
|
+
onFilterChange: (columnKey: string, values: string[] | null) => void
|
|
16
|
+
children?: React.ReactNode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MoreFiltersPopover = React.forwardRef<HTMLDivElement, MoreFiltersPopoverProps>(
|
|
20
|
+
(
|
|
21
|
+
{
|
|
22
|
+
columns,
|
|
23
|
+
columnOrder,
|
|
24
|
+
visibleColumns,
|
|
25
|
+
filters,
|
|
26
|
+
filterOptions,
|
|
27
|
+
onFilterChange,
|
|
28
|
+
children,
|
|
29
|
+
className,
|
|
30
|
+
...props
|
|
31
|
+
},
|
|
32
|
+
ref
|
|
33
|
+
) => {
|
|
34
|
+
const [openGroups, setOpenGroups] = React.useState<Record<string, boolean>>({})
|
|
35
|
+
const [searchQueries, setSearchQueries] = React.useState<Record<string, string>>({})
|
|
36
|
+
|
|
37
|
+
const secondaryFilterKeys = columnOrder.filter((key) => {
|
|
38
|
+
const col = columns[key]
|
|
39
|
+
return col?.filterable && !col.primaryFilter && visibleColumns.has(key)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const activeSecondaryCount = secondaryFilterKeys.filter(
|
|
43
|
+
(key) => (filters[key]?.length ?? 0) > 0
|
|
44
|
+
).length
|
|
45
|
+
|
|
46
|
+
const toggleGroup = (key: string) => {
|
|
47
|
+
setOpenGroups((prev) => ({ ...prev, [key]: !prev[key] }))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const handleSearchChange = (key: string, query: string) => {
|
|
51
|
+
setSearchQueries((prev) => ({ ...prev, [key]: query }))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const getFilteredOptions = (key: string): string[] => {
|
|
55
|
+
const options = filterOptions[key] ?? []
|
|
56
|
+
const query = searchQueries[key]?.toLowerCase() ?? ''
|
|
57
|
+
if (!query) return options
|
|
58
|
+
return options.filter((opt) => opt.toLowerCase().includes(query))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const handleOptionSelect = (columnKey: string, option: string) => {
|
|
62
|
+
const current = filters[columnKey] ?? []
|
|
63
|
+
const next = current.includes(option)
|
|
64
|
+
? current.filter((v) => v !== option)
|
|
65
|
+
: [...current, option]
|
|
66
|
+
onFilterChange(columnKey, next.length > 0 ? next : null)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
ref={ref}
|
|
72
|
+
data-slot="more-filters-popover"
|
|
73
|
+
className={cn('inline-flex', className)}
|
|
74
|
+
{...props}
|
|
75
|
+
>
|
|
76
|
+
<Popover>
|
|
77
|
+
<PopoverTrigger asChild>
|
|
78
|
+
{children ?? (
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
className={cn(
|
|
82
|
+
'inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
83
|
+
activeSecondaryCount > 0 && 'border-primary text-primary'
|
|
84
|
+
)}
|
|
85
|
+
aria-label="Weitere Filter öffnen"
|
|
86
|
+
>
|
|
87
|
+
<SlidersHorizontal className="size-3.5" aria-hidden="true" />
|
|
88
|
+
+ mehr Filter
|
|
89
|
+
{activeSecondaryCount > 0 && (
|
|
90
|
+
<Badge
|
|
91
|
+
variant="default"
|
|
92
|
+
className="ml-0.5 size-4 min-w-0 rounded-full p-0 text-[10px] leading-none"
|
|
93
|
+
aria-label={`${activeSecondaryCount} aktive Filter`}
|
|
94
|
+
>
|
|
95
|
+
{activeSecondaryCount}
|
|
96
|
+
</Badge>
|
|
97
|
+
)}
|
|
98
|
+
</button>
|
|
99
|
+
)}
|
|
100
|
+
</PopoverTrigger>
|
|
101
|
+
|
|
102
|
+
<PopoverContent
|
|
103
|
+
align="start"
|
|
104
|
+
className="w-72 p-0"
|
|
105
|
+
aria-label="Weitere Filter"
|
|
106
|
+
>
|
|
107
|
+
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
|
108
|
+
<span className="text-sm font-semibold">Weitere Filter</span>
|
|
109
|
+
{activeSecondaryCount > 0 && (
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
onClick={() => {
|
|
113
|
+
secondaryFilterKeys.forEach((key) => onFilterChange(key, null))
|
|
114
|
+
}}
|
|
115
|
+
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
116
|
+
>
|
|
117
|
+
Alle zurücksetzen
|
|
118
|
+
</button>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<ul className="divide-y divide-border" role="list">
|
|
123
|
+
{secondaryFilterKeys.map((key) => {
|
|
124
|
+
const col = columns[key]
|
|
125
|
+
const activeValues = filters[key] ?? []
|
|
126
|
+
const isOpen = openGroups[key] ?? false
|
|
127
|
+
const filtered = getFilteredOptions(key)
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<li key={key}>
|
|
131
|
+
<Collapsible open={isOpen} onOpenChange={() => toggleGroup(key)}>
|
|
132
|
+
<CollapsibleTrigger asChild>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
className={cn(
|
|
136
|
+
'flex w-full items-center justify-between px-4 py-2.5 text-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
|
|
137
|
+
activeValues.length > 0 && 'text-primary'
|
|
138
|
+
)}
|
|
139
|
+
aria-expanded={isOpen}
|
|
140
|
+
aria-controls={`filter-group-${key}`}
|
|
141
|
+
>
|
|
142
|
+
<span className="font-medium">{col.label}</span>
|
|
143
|
+
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
144
|
+
{activeValues.length > 0 && (
|
|
145
|
+
<span
|
|
146
|
+
className="max-w-[80px] truncate font-medium text-primary"
|
|
147
|
+
>
|
|
148
|
+
{activeValues.length === 1
|
|
149
|
+
? activeValues[0]
|
|
150
|
+
: `${activeValues.length} ausgewählt`}
|
|
151
|
+
</span>
|
|
152
|
+
)}
|
|
153
|
+
<ChevronDown
|
|
154
|
+
className={cn(
|
|
155
|
+
'size-3.5 shrink-0 transition-transform duration-200',
|
|
156
|
+
isOpen && 'rotate-180'
|
|
157
|
+
)}
|
|
158
|
+
aria-hidden="true"
|
|
159
|
+
/>
|
|
160
|
+
</span>
|
|
161
|
+
</button>
|
|
162
|
+
</CollapsibleTrigger>
|
|
163
|
+
|
|
164
|
+
<CollapsibleContent id={`filter-group-${key}`}>
|
|
165
|
+
<div className="border-t border-border bg-muted/30 px-3 pb-2 pt-2">
|
|
166
|
+
<input
|
|
167
|
+
type="search"
|
|
168
|
+
placeholder="Suchen…"
|
|
169
|
+
value={searchQueries[key] ?? ''}
|
|
170
|
+
onChange={(e) => handleSearchChange(key, e.target.value)}
|
|
171
|
+
className="mb-2 w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
172
|
+
aria-label={`${col.label} durchsuchen`}
|
|
173
|
+
/>
|
|
174
|
+
|
|
175
|
+
<ul role="listbox" aria-label={`Optionen für ${col.label}`}>
|
|
176
|
+
{filtered.map((option) => {
|
|
177
|
+
const isSelected = activeValues.includes(option)
|
|
178
|
+
return (
|
|
179
|
+
<li key={option} role="option" aria-selected={isSelected}>
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
onClick={() => handleOptionSelect(key, option)}
|
|
183
|
+
className={cn(
|
|
184
|
+
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
185
|
+
isSelected && 'font-medium text-primary'
|
|
186
|
+
)}
|
|
187
|
+
>
|
|
188
|
+
<Check
|
|
189
|
+
className={cn(
|
|
190
|
+
'size-3 shrink-0 transition-opacity',
|
|
191
|
+
isSelected ? 'opacity-100' : 'opacity-0'
|
|
192
|
+
)}
|
|
193
|
+
aria-hidden="true"
|
|
194
|
+
/>
|
|
195
|
+
{option}
|
|
196
|
+
</button>
|
|
197
|
+
</li>
|
|
198
|
+
)
|
|
199
|
+
})}
|
|
200
|
+
|
|
201
|
+
{filtered.length === 0 && (
|
|
202
|
+
<li className="px-2 py-1.5 text-xs text-muted-foreground">
|
|
203
|
+
Keine Optionen gefunden
|
|
204
|
+
</li>
|
|
205
|
+
)}
|
|
206
|
+
</ul>
|
|
207
|
+
</div>
|
|
208
|
+
</CollapsibleContent>
|
|
209
|
+
</Collapsible>
|
|
210
|
+
</li>
|
|
211
|
+
)
|
|
212
|
+
})}
|
|
213
|
+
|
|
214
|
+
{secondaryFilterKeys.length === 0 && (
|
|
215
|
+
<li className="px-4 py-3 text-xs text-muted-foreground">
|
|
216
|
+
Keine weiteren Filter verfügbar
|
|
217
|
+
</li>
|
|
218
|
+
)}
|
|
219
|
+
</ul>
|
|
220
|
+
</PopoverContent>
|
|
221
|
+
</Popover>
|
|
222
|
+
</div>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
MoreFiltersPopover.displayName = 'MoreFiltersPopover'
|
|
228
|
+
|
|
229
|
+
export { MoreFiltersPopover }
|
|
230
|
+
export type { MoreFiltersPopoverProps }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
|
|
4
|
+
interface ResultCountProps extends React.ComponentProps<'div'> {
|
|
5
|
+
filtered: number
|
|
6
|
+
total: number
|
|
7
|
+
label: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ResultCount = React.forwardRef<HTMLDivElement, ResultCountProps>(
|
|
11
|
+
({ filtered, total, label, className, ...props }, ref) => {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
ref={ref}
|
|
15
|
+
data-slot="result-count"
|
|
16
|
+
className={cn('px-5 py-2 text-xs text-muted-foreground border-t border-border', className)}
|
|
17
|
+
{...props}
|
|
18
|
+
>
|
|
19
|
+
<span className="font-[family-name:var(--font-grotesk)] font-semibold">{filtered}</span>
|
|
20
|
+
{' von '}
|
|
21
|
+
<span className="font-[family-name:var(--font-grotesk)] font-semibold">{total}</span>
|
|
22
|
+
{' '}
|
|
23
|
+
{label}
|
|
24
|
+
{' angezeigt'}
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
ResultCount.displayName = 'ResultCount'
|
|
31
|
+
|
|
32
|
+
export { ResultCount }
|
|
33
|
+
export type { ResultCountProps }
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { CellType, ColumnConfig, DataRow } from './types'
|
|
3
|
+
import { Badge } from '@/components/ui/badge'
|
|
4
|
+
import { ProgressCircle } from '@/components/ProgressCircle/ProgressCircle'
|
|
5
|
+
import { DoubleTextCell } from '@/components/DoubleTextCell/DoubleTextCell'
|
|
6
|
+
import { cn } from '@/lib/utils'
|
|
7
|
+
|
|
8
|
+
type CellRenderer = (value: unknown, row: DataRow, config: ColumnConfig) => ReactNode
|
|
9
|
+
|
|
10
|
+
// Status badge color mapping based on statusMap CSS class
|
|
11
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
12
|
+
active: 'bg-[var(--lifecycle-active-bg)] text-[var(--lifecycle-active)]',
|
|
13
|
+
nrnd: 'bg-[var(--lifecycle-nrnd-bg)] text-[var(--lifecycle-nrnd)]',
|
|
14
|
+
eol: 'bg-[var(--lifecycle-eol-bg)] text-[var(--lifecycle-eol)]',
|
|
15
|
+
production: 'bg-[var(--lifecycle-production-bg)] text-[var(--lifecycle-production)]',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const INVENTORY_COLORS: Record<string, string> = {
|
|
19
|
+
high: 'bg-[var(--inventory-high-bg)] text-[var(--inventory-high)]',
|
|
20
|
+
medium: 'bg-[var(--inventory-medium-bg)] text-[var(--inventory-medium)]',
|
|
21
|
+
low: 'bg-[var(--inventory-low-bg)] text-[var(--inventory-low)]',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Computes HSL color for score bars: red (0%) → yellow (50%) → green (100%)
|
|
25
|
+
function getScoreBarColor(pct: number): string {
|
|
26
|
+
const hue = (pct / 100) * 142 // 0 = red, 71 = yellow, 142 = green
|
|
27
|
+
return `hsl(${hue}, 75%, 45%)`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const cellRenderers: Record<CellType, CellRenderer> = {
|
|
31
|
+
text: (value) => <span>{String(value ?? '')}</span>,
|
|
32
|
+
|
|
33
|
+
'double-text': (value, row, config) => (
|
|
34
|
+
<DoubleTextCell
|
|
35
|
+
primary={String(value ?? '')}
|
|
36
|
+
secondary={config.secondary ? String(row[config.secondary] ?? '') : undefined}
|
|
37
|
+
/>
|
|
38
|
+
),
|
|
39
|
+
|
|
40
|
+
link: (value) => (
|
|
41
|
+
<a href="#" className="text-primary font-medium hover:underline truncate">
|
|
42
|
+
{String(value ?? '')}
|
|
43
|
+
</a>
|
|
44
|
+
),
|
|
45
|
+
|
|
46
|
+
'status-badge': (value, _row, config) => {
|
|
47
|
+
const statusKey =
|
|
48
|
+
config.statusMap?.[String(value)] ?? String(value).toLowerCase().replace(/\s+/g, '-')
|
|
49
|
+
const colorClass = STATUS_COLORS[statusKey] ?? 'bg-secondary text-secondary-foreground'
|
|
50
|
+
return (
|
|
51
|
+
<Badge variant="secondary" className={cn('border-transparent', colorClass)}>
|
|
52
|
+
{String(value)}
|
|
53
|
+
</Badge>
|
|
54
|
+
)
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
currency: (value, row, config) => {
|
|
58
|
+
const num = typeof value === 'number' ? value : 0
|
|
59
|
+
const cur = config.currencyField ? String(row[config.currencyField] ?? '') : ''
|
|
60
|
+
return (
|
|
61
|
+
<span className="font-[family-name:var(--font-grotesk)] font-medium tabular-nums">
|
|
62
|
+
{num.toFixed(2).replace('.', ',')}
|
|
63
|
+
{cur && <span className="ml-1 text-muted-foreground font-normal">{cur}</span>}
|
|
64
|
+
</span>
|
|
65
|
+
)
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
inventory: (value, _row, config) => {
|
|
69
|
+
const num = typeof value === 'number' ? value : 0
|
|
70
|
+
const level = config.levelFn?.(num) ?? 'medium'
|
|
71
|
+
const formatted = config.formatFn?.(num) ?? String(num)
|
|
72
|
+
const colorClass = INVENTORY_COLORS[level] ?? INVENTORY_COLORS.medium
|
|
73
|
+
return (
|
|
74
|
+
<Badge variant="secondary" className={cn('border-transparent', colorClass)}>
|
|
75
|
+
{formatted}
|
|
76
|
+
</Badge>
|
|
77
|
+
)
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
'inventory-label': (value, _row, config) => {
|
|
81
|
+
const num = typeof value === 'number' ? value : 0
|
|
82
|
+
const level = config.levelFn?.(num) ?? 'medium'
|
|
83
|
+
const labelMap = config.labelMap ?? { high: 'Hoch', medium: 'Mittel', low: 'Niedrig' }
|
|
84
|
+
const colorClass = INVENTORY_COLORS[level] ?? INVENTORY_COLORS.medium
|
|
85
|
+
return (
|
|
86
|
+
<Badge variant="secondary" className={cn('border-transparent', colorClass)}>
|
|
87
|
+
{labelMap[level]}
|
|
88
|
+
</Badge>
|
|
89
|
+
)
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
progress: (value) => {
|
|
93
|
+
const pct = typeof value === 'number' ? value : 0
|
|
94
|
+
return <ProgressCircle value={pct} />
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// favorite and expand are handled inline by the view components (they need callbacks)
|
|
98
|
+
favorite: () => null,
|
|
99
|
+
expand: () => null,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Score bar renderer for expansion drawer tables
|
|
103
|
+
export function renderScoreBar(value: number): ReactNode {
|
|
104
|
+
const pct = value <= 1 ? value * 100 : value
|
|
105
|
+
const color = getScoreBarColor(pct)
|
|
106
|
+
return (
|
|
107
|
+
<div className="flex items-center gap-2">
|
|
108
|
+
<div className="relative h-2 flex-1 overflow-hidden rounded-full bg-muted">
|
|
109
|
+
<div
|
|
110
|
+
className="h-full rounded-full transition-all"
|
|
111
|
+
style={{ width: `${pct}%`, backgroundColor: color }}
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
<span className="text-xs font-medium tabular-nums w-8 text-right">
|
|
115
|
+
{pct.toFixed(0)}%
|
|
116
|
+
</span>
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|