@fastnd/components 1.0.31 → 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.
Files changed (29) hide show
  1. package/dist/components/QuickAccessCard/QuickAccessCard.d.ts +12 -0
  2. package/dist/components/ScoreBar/ScoreBar.d.ts +8 -0
  3. package/dist/components/index.d.ts +2 -0
  4. package/dist/examples/dashboard/DashboardPage/DashboardPage.tsx +1 -1
  5. package/dist/examples/dashboard/ProjectList/ProjectList.tsx +87 -47
  6. package/dist/examples/dashboard/StatusDonutChart/StatusDonutChart.tsx +41 -56
  7. package/dist/examples/dashboard/constants.ts +20 -6
  8. package/dist/examples/dashboard/types.ts +2 -0
  9. package/dist/examples/data-visualization/CardCarouselPanel/CardCarouselPanel.tsx +171 -0
  10. package/dist/examples/data-visualization/CellRenderers/CellRenderers.tsx +171 -0
  11. package/dist/examples/data-visualization/ColumnConfigPopover/ColumnConfigPopover.tsx +124 -0
  12. package/dist/examples/data-visualization/DataCardView/DataCardView.tsx +223 -0
  13. package/dist/examples/data-visualization/DataExplorerPagination/DataExplorerPagination.tsx +143 -0
  14. package/dist/examples/data-visualization/DataExplorerToolbar/DataExplorerToolbar.tsx +88 -0
  15. package/dist/examples/data-visualization/DataListView/DataListView.tsx +283 -0
  16. package/dist/examples/data-visualization/DataTableView/DataTableView.tsx +455 -0
  17. package/dist/examples/data-visualization/DataVisualizationPage/DataVisualizationPage.tsx +151 -0
  18. package/dist/examples/data-visualization/FilterChipGroup/FilterChipGroup.tsx +125 -0
  19. package/dist/examples/data-visualization/MoreFiltersPopover/MoreFiltersPopover.tsx +132 -0
  20. package/dist/examples/data-visualization/constants.ts +587 -0
  21. package/dist/examples/data-visualization/hooks/use-column-config.ts +76 -0
  22. package/dist/examples/data-visualization/hooks/use-data-explorer-state.ts +318 -0
  23. package/dist/examples/data-visualization/index.ts +1 -0
  24. package/dist/examples/data-visualization/types.ts +110 -0
  25. package/dist/examples/quickaccess/QuickAccess/QuickAccess.tsx +97 -0
  26. package/dist/examples/quickaccess/index.ts +2 -0
  27. package/dist/examples/quickaccess/types.ts +11 -0
  28. package/dist/fastnd-components.js +5708 -5590
  29. package/package.json +1 -1
@@ -0,0 +1,76 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react'
2
+ import { DATA_SOURCES } from '../constants'
3
+ import type { DomainKey } from '../types'
4
+
5
+ interface UseColumnConfigOptions {
6
+ activeDomain: DomainKey
7
+ }
8
+
9
+ interface ColumnConfigState {
10
+ columnOrder: string[]
11
+ columnVisibility: Record<string, boolean>
12
+ visibleColumns: string[]
13
+ reorderColumn: (activeId: string, overId: string) => void
14
+ toggleColumnVisibility: (key: string) => void
15
+ resetColumns: () => void
16
+ }
17
+
18
+ function buildInitialState(domain: DomainKey) {
19
+ const columns = DATA_SOURCES[domain].columns
20
+ const columnOrder = Object.keys(columns)
21
+ const columnVisibility: Record<string, boolean> = {}
22
+ for (const [k, col] of Object.entries(columns)) {
23
+ columnVisibility[k] = col.visible !== false
24
+ }
25
+ return { columnOrder, columnVisibility }
26
+ }
27
+
28
+ export function useColumnConfig({ activeDomain }: UseColumnConfigOptions): ColumnConfigState {
29
+ const [columnOrder, setColumnOrder] = useState<string[]>(
30
+ () => buildInitialState(activeDomain).columnOrder
31
+ )
32
+ const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>(
33
+ () => buildInitialState(activeDomain).columnVisibility
34
+ )
35
+
36
+ useEffect(() => {
37
+ const { columnOrder: order, columnVisibility: visibility } = buildInitialState(activeDomain)
38
+ setColumnOrder(order)
39
+ setColumnVisibility(visibility)
40
+ }, [activeDomain])
41
+
42
+ const columns = DATA_SOURCES[activeDomain].columns
43
+
44
+ const visibleColumns = useMemo(
45
+ () => columnOrder.filter((k) => columnVisibility[k] && columns[k] !== undefined),
46
+ [columnOrder, columnVisibility, columns]
47
+ )
48
+
49
+ const reorderColumn = useCallback((activeId: string, overId: string) => {
50
+ setColumnOrder((prev) => {
51
+ const result = prev.filter((k) => k !== activeId)
52
+ const overIndex = result.indexOf(overId)
53
+ result.splice(overIndex, 0, activeId)
54
+ return result
55
+ })
56
+ }, [])
57
+
58
+ const toggleColumnVisibility = useCallback((key: string) => {
59
+ setColumnVisibility((prev) => ({ ...prev, [key]: !prev[key] }))
60
+ }, [])
61
+
62
+ const resetColumns = useCallback(() => {
63
+ const { columnOrder: order, columnVisibility: visibility } = buildInitialState(activeDomain)
64
+ setColumnOrder(order)
65
+ setColumnVisibility(visibility)
66
+ }, [activeDomain])
67
+
68
+ return {
69
+ columnOrder,
70
+ columnVisibility,
71
+ visibleColumns,
72
+ reorderColumn,
73
+ toggleColumnVisibility,
74
+ resetColumns,
75
+ }
76
+ }
@@ -0,0 +1,318 @@
1
+ import { useState, useMemo, useCallback } from 'react'
2
+ import type { DomainKey, DomainConfig, ViewMode, SortState, FilterState } from '../types'
3
+ import { DATA_SOURCES } from '../constants'
4
+
5
+ export interface DataExplorerState {
6
+ // Domain
7
+ activeDomain: DomainKey
8
+ setActiveDomain: (domain: DomainKey) => void
9
+ domainConfig: DomainConfig
10
+
11
+ // View
12
+ viewMode: ViewMode
13
+ setViewMode: (mode: ViewMode) => void
14
+
15
+ // Sorting
16
+ sort: SortState
17
+ toggleSort: (column: string) => void
18
+
19
+ // Filtering
20
+ filters: FilterState
21
+ toggleFilter: (column: string, value: string) => void
22
+ clearFilter: (column: string) => void
23
+ clearAllFilters: () => void
24
+ clearSecondaryFilters: () => void
25
+ hasActiveFilters: boolean
26
+ activeFilterCount: number
27
+ getFilterOptions: (colKey: string) => string[]
28
+
29
+ // Search
30
+ searchTerm: string
31
+ setSearchTerm: (term: string) => void
32
+
33
+ // Expansion
34
+ expandedRows: Set<string>
35
+ toggleExpansion: (rowId: string, field: string) => void
36
+
37
+ // Favorites
38
+ favorites: Set<string>
39
+ toggleFavorite: (id: string) => void
40
+
41
+ // Pagination
42
+ currentPage: number
43
+ pageSize: number
44
+ setPage: (page: number) => void
45
+ setPageSize: (size: number) => void
46
+ totalFiltered: number
47
+ totalPages: number
48
+
49
+ // Derived data
50
+ paginatedData: Record<string, unknown>[]
51
+ visibleColumns: string[]
52
+ }
53
+
54
+ function initFavorites(data: Record<string, unknown>[]): Set<string> {
55
+ const favs = new Set<string>()
56
+ for (const row of data) {
57
+ if (row.favorite) favs.add(row.id as string)
58
+ }
59
+ return favs
60
+ }
61
+
62
+ export function useDataExplorerState(
63
+ initialDomain: DomainKey = 'products',
64
+ ): DataExplorerState {
65
+ const [activeDomain, setActiveDomainRaw] = useState<DomainKey>(initialDomain)
66
+ const [viewMode, setViewModeRaw] = useState<ViewMode>('table')
67
+ const [sort, setSort] = useState<SortState>({ column: null, direction: 'asc' })
68
+ const [filters, setFilters] = useState<FilterState>({})
69
+ const [searchTerm, setSearchTermRaw] = useState('')
70
+ const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
71
+ const [favorites, setFavorites] = useState<Set<string>>(() =>
72
+ initFavorites(DATA_SOURCES[initialDomain].data),
73
+ )
74
+ const [currentPage, setCurrentPage] = useState(1)
75
+ const [pageSize, setPageSizeRaw] = useState(25)
76
+
77
+ const domainConfig = useMemo<DomainConfig>(
78
+ () => DATA_SOURCES[activeDomain],
79
+ [activeDomain],
80
+ )
81
+
82
+ const setViewMode = useCallback((mode: ViewMode) => {
83
+ setViewModeRaw(mode)
84
+ setSort({ column: null, direction: 'asc' })
85
+ }, [])
86
+
87
+ const setActiveDomain = useCallback((domain: DomainKey) => {
88
+ setActiveDomainRaw(domain)
89
+ setSort({ column: null, direction: 'asc' })
90
+ setFilters({})
91
+ setSearchTermRaw('')
92
+ setExpandedRows(new Set())
93
+ setCurrentPage(1)
94
+ setFavorites(initFavorites(DATA_SOURCES[domain].data))
95
+ }, [])
96
+
97
+ const toggleSort = useCallback((column: string) => {
98
+ setSort((prev) => {
99
+ if (prev.column === column) {
100
+ return { column, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
101
+ }
102
+ return { column, direction: 'asc' }
103
+ })
104
+ setCurrentPage(1)
105
+ }, [])
106
+
107
+ const toggleFilter = useCallback((column: string, value: string) => {
108
+ setFilters((prev) => {
109
+ const current = prev[column] ?? []
110
+ const next = current.includes(value)
111
+ ? current.filter((v) => v !== value)
112
+ : [...current, value]
113
+ return { ...prev, [column]: next }
114
+ })
115
+ setCurrentPage(1)
116
+ }, [])
117
+
118
+ const clearFilter = useCallback((column: string) => {
119
+ setFilters((prev) => {
120
+ const next = { ...prev }
121
+ delete next[column]
122
+ return next
123
+ })
124
+ setCurrentPage(1)
125
+ }, [])
126
+
127
+ const clearAllFilters = useCallback(() => {
128
+ setFilters({})
129
+ setSearchTermRaw('')
130
+ setCurrentPage(1)
131
+ }, [])
132
+
133
+ const clearSecondaryFilters = useCallback(() => {
134
+ setFilters((prev) => {
135
+ const cols = DATA_SOURCES[activeDomain].columns
136
+ const next: FilterState = {}
137
+ for (const [k, v] of Object.entries(prev)) {
138
+ if (cols[k]?.primaryFilter) next[k] = v
139
+ }
140
+ return next
141
+ })
142
+ setCurrentPage(1)
143
+ // activeDomain is stable within a single render; callback captures it via closure
144
+ }, [activeDomain])
145
+
146
+ const setSearchTerm = useCallback((term: string) => {
147
+ setSearchTermRaw(term)
148
+ setCurrentPage(1)
149
+ }, [])
150
+
151
+ const toggleExpansion = useCallback((rowId: string, field: string) => {
152
+ const key = `${rowId}::${field}`
153
+ setExpandedRows((prev) => {
154
+ const next = new Set(prev)
155
+ if (next.has(key)) {
156
+ next.delete(key)
157
+ } else {
158
+ next.add(key)
159
+ }
160
+ return next
161
+ })
162
+ }, [])
163
+
164
+ const toggleFavorite = useCallback((id: string) => {
165
+ setFavorites((prev) => {
166
+ const next = new Set(prev)
167
+ if (next.has(id)) {
168
+ next.delete(id)
169
+ } else {
170
+ next.add(id)
171
+ }
172
+ return next
173
+ })
174
+ }, [])
175
+
176
+ const setPage = useCallback((page: number) => {
177
+ setCurrentPage(page)
178
+ }, [])
179
+
180
+ const setPageSize = useCallback((size: number) => {
181
+ setPageSizeRaw(size)
182
+ setCurrentPage(1)
183
+ }, [])
184
+
185
+ const getFilterOptions = useCallback(
186
+ (colKey: string): string[] => {
187
+ const col = DATA_SOURCES[activeDomain].columns[colKey]
188
+ if (!col) return []
189
+ if (col.filterOptions) return col.filterOptions
190
+ const vals = new Set<string>()
191
+ for (const row of DATA_SOURCES[activeDomain].data) {
192
+ const v = row[colKey]
193
+ if (v != null && v !== '') vals.add(String(v))
194
+ }
195
+ return [...vals].sort((a, b) => a.localeCompare(b, 'de'))
196
+ },
197
+ [activeDomain],
198
+ )
199
+
200
+ const filteredData = useMemo(() => {
201
+ const { data, columns } = DATA_SOURCES[activeDomain]
202
+ const term = searchTerm.toLowerCase()
203
+
204
+ return data.filter((row) => {
205
+ // search
206
+ if (term) {
207
+ let matched = false
208
+ for (const [k, col] of Object.entries(columns)) {
209
+ if (!col.searchable) continue
210
+ const v = row[k]
211
+ if (v != null && String(v).toLowerCase().includes(term)) {
212
+ matched = true
213
+ break
214
+ }
215
+ if (col.secondary) {
216
+ const sv = row[col.secondary]
217
+ if (sv != null && String(sv).toLowerCase().includes(term)) {
218
+ matched = true
219
+ break
220
+ }
221
+ }
222
+ }
223
+ if (!matched) return false
224
+ }
225
+
226
+ // filters
227
+ for (const [colKey, selected] of Object.entries(filters)) {
228
+ if (!selected || selected.length === 0) continue
229
+ const col = columns[colKey]
230
+ if (!col) continue
231
+ if (col.filterFn) {
232
+ if (!selected.some((val) => col.filterFn!(row, val))) return false
233
+ } else {
234
+ if (!selected.includes(String(row[colKey] ?? ''))) return false
235
+ }
236
+ }
237
+
238
+ return true
239
+ })
240
+ }, [activeDomain, filters, searchTerm])
241
+
242
+ const sortedData = useMemo(() => {
243
+ if (!sort.column) return filteredData
244
+ const col = DATA_SOURCES[activeDomain].columns[sort.column]
245
+ if (!col) return filteredData
246
+
247
+ const key = sort.column
248
+ const dir = sort.direction === 'asc' ? 1 : -1
249
+
250
+ return [...filteredData].sort((a, b) => {
251
+ let va = a[key] ?? ''
252
+ let vb = b[key] ?? ''
253
+ if (typeof va === 'number' && typeof vb === 'number') {
254
+ return (va - vb) * dir
255
+ }
256
+ return String(va).localeCompare(String(vb), 'de') * dir
257
+ })
258
+ }, [filteredData, sort, activeDomain])
259
+
260
+ const totalFiltered = useMemo(() => filteredData.length, [filteredData])
261
+
262
+ const totalPages = useMemo(
263
+ () => Math.max(1, Math.ceil(totalFiltered / pageSize)),
264
+ [totalFiltered, pageSize],
265
+ )
266
+
267
+ const paginatedData = useMemo(() => {
268
+ const start = (currentPage - 1) * pageSize
269
+ return sortedData.slice(start, start + pageSize)
270
+ }, [sortedData, currentPage, pageSize])
271
+
272
+ const visibleColumns = useMemo(() => {
273
+ const { columns } = DATA_SOURCES[activeDomain]
274
+ return Object.keys(columns).filter((k) => columns[k].visible !== false)
275
+ }, [activeDomain])
276
+
277
+ const hasActiveFilters = useMemo(() => {
278
+ if (searchTerm) return true
279
+ return Object.values(filters).some((v) => v && v.length > 0)
280
+ }, [filters, searchTerm])
281
+
282
+ const activeFilterCount = useMemo(
283
+ () => Object.values(filters).reduce((acc, v) => acc + (v?.length ?? 0), 0),
284
+ [filters],
285
+ )
286
+
287
+ return {
288
+ activeDomain,
289
+ setActiveDomain,
290
+ domainConfig,
291
+ viewMode,
292
+ setViewMode,
293
+ sort,
294
+ toggleSort,
295
+ filters,
296
+ toggleFilter,
297
+ clearFilter,
298
+ clearAllFilters,
299
+ clearSecondaryFilters,
300
+ hasActiveFilters,
301
+ activeFilterCount,
302
+ getFilterOptions,
303
+ searchTerm,
304
+ setSearchTerm,
305
+ expandedRows,
306
+ toggleExpansion,
307
+ favorites,
308
+ toggleFavorite,
309
+ currentPage,
310
+ pageSize,
311
+ setPage,
312
+ setPageSize,
313
+ totalFiltered,
314
+ totalPages,
315
+ paginatedData,
316
+ visibleColumns,
317
+ }
318
+ }
@@ -0,0 +1 @@
1
+ export { DataVisualizationPage } from './DataVisualizationPage/DataVisualizationPage'
@@ -0,0 +1,110 @@
1
+ import type React from 'react'
2
+
3
+ export type DomainKey = 'products' | 'projects' | 'customers' | 'applications'
4
+
5
+ export type CellType =
6
+ | 'text'
7
+ | 'double-text'
8
+ | 'link'
9
+ | 'status-badge'
10
+ | 'currency'
11
+ | 'inventory'
12
+ | 'favorite'
13
+ | 'expand'
14
+ | 'score-bar'
15
+
16
+ export type ViewMode = 'table' | 'list' | 'card'
17
+
18
+ export type SortDirection = 'asc' | 'desc'
19
+
20
+ export interface SortState {
21
+ column: string | null
22
+ direction: SortDirection
23
+ }
24
+
25
+ export type FilterState = Record<string, string[]>
26
+
27
+ export interface ExpandColumnDef {
28
+ key: string
29
+ label: string
30
+ mapTo?: string
31
+ secondaryKey?: string
32
+ bold?: boolean
33
+ muted?: boolean
34
+ type?: CellType
35
+ }
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
+
45
+ export interface ColumnDef {
46
+ label: string
47
+ type: CellType
48
+ sortable: boolean
49
+ filterable: boolean
50
+ primaryFilter?: boolean
51
+ visible: boolean
52
+ searchable?: boolean
53
+ secondary?: string
54
+ currencyField?: string
55
+ statusMap?: Record<string, string>
56
+ levelFn?: (v: number) => 'high' | 'medium' | 'low'
57
+ formatFn?: (v: number) => string
58
+ labelMap?: Record<string, string>
59
+ filterOptions?: string[]
60
+ filterFn?: (row: Record<string, unknown>, val: string) => boolean
61
+ expandColumns?: ExpandColumnDef[]
62
+ expandTitleFn?: (row: Record<string, unknown>) => string
63
+ expandLabel?: string
64
+ expandIcon?: string
65
+ headerIcon?: string
66
+ headerTooltip?: string
67
+ hideTablet?: boolean
68
+ hideMobile?: boolean
69
+ rowLines?: number
70
+ render?: (val: unknown, row: Record<string, unknown>, opts: RenderCellOptions) => React.ReactNode
71
+ }
72
+
73
+ export interface ListLayout {
74
+ titleField: string
75
+ metaFields: string[]
76
+ badgeFields: string[]
77
+ valueField: string | null
78
+ expandFields?: string[]
79
+ expandField?: string
80
+ }
81
+
82
+ export interface CardRow {
83
+ label: string
84
+ field: string
85
+ rendererOverride?: string
86
+ }
87
+
88
+ export interface CardLayout {
89
+ titleField: string
90
+ subtitleField: string
91
+ badgeFields: string[]
92
+ rows: CardRow[]
93
+ footerField: string
94
+ expandFields?: string[]
95
+ expandField?: string
96
+ }
97
+
98
+ export interface DomainLayout {
99
+ list: ListLayout
100
+ card: CardLayout
101
+ }
102
+
103
+ export interface DomainConfig {
104
+ key: DomainKey
105
+ label: string
106
+ resultLabel: string
107
+ columns: Record<string, ColumnDef>
108
+ layout: DomainLayout
109
+ data: Record<string, unknown>[]
110
+ }
@@ -0,0 +1,97 @@
1
+ import * as React from 'react'
2
+ import { ChevronDown } from 'lucide-react'
3
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
4
+ import { QuickAccessCard } from '@/components/QuickAccessCard/QuickAccessCard'
5
+ import { cn } from '@/lib/utils'
6
+ import type { QuickAccessItem } from '../types'
7
+
8
+ interface QuickAccessProps extends React.ComponentProps<'section'> {
9
+ items: QuickAccessItem[]
10
+ defaultOpen?: boolean
11
+ activeItemId?: string
12
+ onSelect?: (item: QuickAccessItem) => void
13
+ }
14
+
15
+ const QuickAccess = React.forwardRef<HTMLElement, QuickAccessProps>(
16
+ ({ items, defaultOpen = true, activeItemId, onSelect, className, ...props }, ref) => {
17
+ const titleId = React.useId()
18
+
19
+ return (
20
+ <section
21
+ ref={ref}
22
+ data-slot="quick-access"
23
+ aria-labelledby={titleId}
24
+ className={cn(
25
+ 'bg-background border border-border rounded-lg overflow-hidden shadow-[var(--shadow-card)]',
26
+ className,
27
+ )}
28
+ {...props}
29
+ >
30
+ <Collapsible defaultOpen={defaultOpen}>
31
+ {/* Header */}
32
+ <div className="flex items-center gap-2 px-5 py-3 border-b border-border select-none">
33
+ <CollapsibleTrigger
34
+ className={cn(
35
+ 'group inline-flex items-center justify-center h-7 w-7 rounded-sm',
36
+ 'text-muted-foreground bg-transparent border-none cursor-pointer',
37
+ 'transition-[background-color,color] duration-150',
38
+ 'hover:bg-muted hover:text-foreground',
39
+ 'focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2',
40
+ )}
41
+ aria-label="Schnellzugriffe ein-/ausklappen"
42
+ >
43
+ <ChevronDown
44
+ size={16}
45
+ className="transition-transform duration-300 group-data-[state=closed]:-rotate-90"
46
+ />
47
+ </CollapsibleTrigger>
48
+
49
+ <h2
50
+ id={titleId}
51
+ className="text-[0.8125rem] font-medium tracking-[0.06em] uppercase text-foreground"
52
+ style={{ fontFamily: 'var(--font-clash)' }}
53
+ >
54
+ Schnellzugriffe
55
+ </h2>
56
+ </div>
57
+
58
+ {/* Cards */}
59
+ <CollapsibleContent>
60
+ <div
61
+ role="list"
62
+ className="flex flex-wrap gap-3 p-4 md:flex-nowrap md:gap-4 md:p-5"
63
+ >
64
+ {items.map((item) => (
65
+ <QuickAccessCard
66
+ key={item.id}
67
+ role="listitem"
68
+ label={item.label}
69
+ count={item.count}
70
+ countLabel={item.countLabel}
71
+ icon={item.icon}
72
+ variant={item.variant}
73
+ isActive={activeItemId === item.id}
74
+ href={item.href ?? `#${item.id}`}
75
+ className="flex-[1_1_100%] min-[480px]:flex-[1_1_calc(50%_-_0.375rem)] md:flex-1"
76
+ onClick={
77
+ onSelect
78
+ ? (e) => {
79
+ e.preventDefault()
80
+ onSelect(item)
81
+ }
82
+ : undefined
83
+ }
84
+ />
85
+ ))}
86
+ </div>
87
+ </CollapsibleContent>
88
+ </Collapsible>
89
+ </section>
90
+ )
91
+ },
92
+ )
93
+
94
+ QuickAccess.displayName = 'QuickAccess'
95
+
96
+ export { QuickAccess }
97
+ export type { QuickAccessProps }
@@ -0,0 +1,2 @@
1
+ export * from './QuickAccess/QuickAccess'
2
+ export type * from './types'
@@ -0,0 +1,11 @@
1
+ import type * as React from 'react'
2
+
3
+ export interface QuickAccessItem {
4
+ id: string
5
+ label: string
6
+ count: number
7
+ countLabel?: string // defaults to 'Projekte'
8
+ href?: string
9
+ variant?: 'default' | 'overdue'
10
+ icon: React.ReactNode
11
+ }