@fastnd/components 1.0.26 → 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.
Files changed (43) hide show
  1. package/dist/components/ColumnConfigPopover/ColumnConfigPopover.d.ts +20 -0
  2. package/dist/components/DoubleTextCell/DoubleTextCell.d.ts +9 -0
  3. package/dist/components/ProgressCircle/ProgressCircle.d.ts +9 -0
  4. package/dist/components/index.d.ts +3 -0
  5. package/dist/components/ui/badge.d.ts +1 -1
  6. package/dist/components/ui/button.d.ts +1 -1
  7. package/dist/components/ui/input-group.d.ts +1 -1
  8. package/dist/components/ui/item.d.ts +1 -1
  9. package/dist/components/ui/tabs.d.ts +1 -1
  10. package/dist/examples/dashboard/DashboardPage/DashboardPage.tsx +54 -0
  11. package/dist/examples/dashboard/ProjectList/ProjectList.tsx +120 -0
  12. package/dist/examples/dashboard/StatusDonutChart/StatusDonutChart.tsx +123 -0
  13. package/dist/examples/dashboard/StatusFilterLegend/StatusFilterLegend.tsx +70 -0
  14. package/dist/examples/dashboard/StatusOverview/StatusOverview.tsx +51 -0
  15. package/dist/examples/dashboard/constants.ts +18 -0
  16. package/dist/examples/dashboard/hooks/use-dashboard-state.ts +98 -0
  17. package/dist/examples/dashboard/index.ts +6 -0
  18. package/dist/examples/dashboard/types.ts +19 -0
  19. package/dist/examples/data-visualization/DataGrid/DataGrid.tsx +136 -0
  20. package/dist/examples/data-visualization/DataGridCardView/DataGridCardView.tsx +179 -0
  21. package/dist/examples/data-visualization/DataGridListView/DataGridListView.tsx +190 -0
  22. package/dist/examples/data-visualization/DataGridPage/DataGridPage.tsx +43 -0
  23. package/dist/examples/data-visualization/DataGridPagination/DataGridPagination.tsx +111 -0
  24. package/dist/examples/data-visualization/DataGridTableView/DataGridTableView.tsx +282 -0
  25. package/dist/examples/data-visualization/DataGridToolbar/DataGridToolbar.tsx +283 -0
  26. package/dist/examples/data-visualization/DomainSwitcher/DomainSwitcher.tsx +41 -0
  27. package/dist/examples/data-visualization/ExpansionDrawer/ExpansionDrawer.tsx +139 -0
  28. package/dist/examples/data-visualization/MoreFiltersPopover/MoreFiltersPopover.tsx +230 -0
  29. package/dist/examples/data-visualization/ResultCount/ResultCount.tsx +33 -0
  30. package/dist/examples/data-visualization/cell-renderers.tsx +119 -0
  31. package/dist/examples/data-visualization/constants.ts +1251 -0
  32. package/dist/examples/data-visualization/hooks/use-data-grid-columns.ts +65 -0
  33. package/dist/examples/data-visualization/hooks/use-data-grid-expansion.ts +40 -0
  34. package/dist/examples/data-visualization/hooks/use-data-grid-favorites.ts +41 -0
  35. package/dist/examples/data-visualization/hooks/use-data-grid-filters.ts +61 -0
  36. package/dist/examples/data-visualization/hooks/use-data-grid-pagination.ts +32 -0
  37. package/dist/examples/data-visualization/hooks/use-data-grid-sort.ts +32 -0
  38. package/dist/examples/data-visualization/hooks/use-data-grid-state.ts +133 -0
  39. package/dist/examples/data-visualization/hooks/use-filtered-data.ts +84 -0
  40. package/dist/examples/data-visualization/index.ts +10 -0
  41. package/dist/examples/data-visualization/types.ts +103 -0
  42. package/dist/fastnd-components.js +18759 -15519
  43. package/package.json +2 -1
@@ -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
+ }