@nestledjs/data-browser 0.1.0

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 (35) hide show
  1. package/.babelrc +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +320 -0
  4. package/eslint.config.mjs +12 -0
  5. package/package.json +44 -0
  6. package/project.json +39 -0
  7. package/src/index.ts +28 -0
  8. package/src/lib/admin-data.spec.tsx +10 -0
  9. package/src/lib/admin-data.tsx +9 -0
  10. package/src/lib/components/filters/DateRangeFilter.tsx +88 -0
  11. package/src/lib/components/filters/NumberRangeFilter.tsx +111 -0
  12. package/src/lib/components/filters/RelationComponents.tsx +176 -0
  13. package/src/lib/components/filters/RelationFilterField.tsx +106 -0
  14. package/src/lib/components/filters/index.ts +5 -0
  15. package/src/lib/components/index.ts +2 -0
  16. package/src/lib/components/shared/AdminBreadcrumbs.tsx +88 -0
  17. package/src/lib/components/shared/AdminErrorStates.tsx +180 -0
  18. package/src/lib/components/shared/AdminStatusDisplay.tsx +292 -0
  19. package/src/lib/components/shared/index.ts +3 -0
  20. package/src/lib/context/AdminDataContext.tsx +74 -0
  21. package/src/lib/hooks/useAdminList.ts +42 -0
  22. package/src/lib/hooks/useClickOutside.ts +21 -0
  23. package/src/lib/hooks/useDebounce.ts +16 -0
  24. package/src/lib/hooks/useRelationData.ts +114 -0
  25. package/src/lib/layouts/AdminDataLayout.tsx +251 -0
  26. package/src/lib/pages/AdminDataCreatePage.tsx +415 -0
  27. package/src/lib/pages/AdminDataEditPage.tsx +777 -0
  28. package/src/lib/pages/AdminDataIndexPage.tsx +50 -0
  29. package/src/lib/pages/AdminDataListPage.tsx +921 -0
  30. package/src/lib/types/index.ts +51 -0
  31. package/src/lib/utils/graphql-utils.ts +538 -0
  32. package/src/lib/utils/secure-storage.ts +305 -0
  33. package/src/lib/utils/string-utils.ts +53 -0
  34. package/tsconfig.json +17 -0
  35. package/tsconfig.lib.json +20 -0
@@ -0,0 +1,111 @@
1
+ import { formatFieldName } from '../../utils/string-utils'
2
+
3
+ interface NumberRangeFilterProps {
4
+ fieldName: string
5
+ fieldType: string
6
+ currentValue: any
7
+ onChange: (value: any) => void
8
+ }
9
+
10
+ export function NumberRangeFilter({
11
+ fieldName,
12
+ fieldType,
13
+ currentValue,
14
+ onChange
15
+ }: Readonly<NumberRangeFilterProps>) {
16
+ // Parse current value if it's a range object
17
+ const minValue = currentValue?.gte !== undefined ? currentValue.gte.toString() : ''
18
+ const maxValue = currentValue?.lte !== undefined ? currentValue.lte.toString() : ''
19
+
20
+ const parseNumber = (value: string) => {
21
+ if (!value) return undefined
22
+
23
+ // Parse based on field type
24
+ if (fieldType === 'int' || fieldType === 'bigint') {
25
+ const parsed = parseInt(value, 10)
26
+ return isNaN(parsed) ? undefined : parsed
27
+ } else if (fieldType === 'float' || fieldType === 'decimal') {
28
+ const parsed = parseFloat(value)
29
+ return isNaN(parsed) ? undefined : parsed
30
+ }
31
+ return undefined
32
+ }
33
+
34
+ const handleMinChange = (value: string) => {
35
+ const newValue = { ...currentValue }
36
+ const parsedValue = parseNumber(value)
37
+
38
+ if (parsedValue !== undefined) {
39
+ newValue.gte = parsedValue
40
+ } else {
41
+ delete newValue.gte
42
+ }
43
+
44
+ // If no min or max value, clear the filter entirely
45
+ if (newValue.gte === undefined && newValue.lte === undefined) {
46
+ onChange(undefined)
47
+ } else {
48
+ onChange(newValue)
49
+ }
50
+ }
51
+
52
+ const handleMaxChange = (value: string) => {
53
+ const newValue = { ...currentValue }
54
+ const parsedValue = parseNumber(value)
55
+
56
+ if (parsedValue !== undefined) {
57
+ newValue.lte = parsedValue
58
+ } else {
59
+ delete newValue.lte
60
+ }
61
+
62
+ // If no min or max value, clear the filter entirely
63
+ if (newValue.gte === undefined && newValue.lte === undefined) {
64
+ onChange(undefined)
65
+ } else {
66
+ onChange(newValue)
67
+ }
68
+ }
69
+
70
+ // Determine input step based on field type
71
+ const step = fieldType === 'int' || fieldType === 'bigint' ? '1' : 'any'
72
+
73
+ return (
74
+ <div className="space-y-1">
75
+ <label className="block text-sm font-medium text-gray-700">
76
+ {formatFieldName(fieldName)}
77
+ </label>
78
+ <div className="grid grid-cols-2 gap-2">
79
+ <div>
80
+ <label className="block text-xs text-gray-500 mb-1">Min</label>
81
+ <input
82
+ type="number"
83
+ step={step}
84
+ value={minValue}
85
+ onChange={(e) => handleMinChange(e.target.value)}
86
+ placeholder="No minimum"
87
+ className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-green-web focus:border-green-web"
88
+ />
89
+ </div>
90
+ <div>
91
+ <label className="block text-xs text-gray-500 mb-1">Max</label>
92
+ <input
93
+ type="number"
94
+ step={step}
95
+ value={maxValue}
96
+ onChange={(e) => handleMaxChange(e.target.value)}
97
+ placeholder="No maximum"
98
+ className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-green-web focus:border-green-web"
99
+ />
100
+ </div>
101
+ </div>
102
+ {(minValue || maxValue) && (
103
+ <div className="text-xs text-gray-500">
104
+ {minValue && maxValue && `${minValue} to ${maxValue}`}
105
+ {minValue && !maxValue && `≥ ${minValue}`}
106
+ {!minValue && maxValue && `≤ ${maxValue}`}
107
+ </div>
108
+ )}
109
+ </div>
110
+ )
111
+ }
@@ -0,0 +1,176 @@
1
+ import { getItemDisplayName } from '../../utils/string-utils'
2
+
3
+ // Dropdown button component
4
+ export function RelationDropdownButton({
5
+ currentItem,
6
+ relatedModelName,
7
+ isOpen,
8
+ onClick
9
+ }: Readonly<{
10
+ currentItem: any
11
+ relatedModelName: string
12
+ isOpen: boolean
13
+ onClick: () => void
14
+ }>) {
15
+ return (
16
+ <button
17
+ type="button"
18
+ onClick={onClick}
19
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-web focus:border-green-web text-sm text-left bg-white flex items-center justify-between"
20
+ >
21
+ <span className={currentItem ? 'text-gray-900' : 'text-gray-500'}>
22
+ {currentItem ? getItemDisplayName(currentItem) : `Select ${relatedModelName.toLowerCase()}...`}
23
+ </span>
24
+ <svg
25
+ className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
26
+ fill="none"
27
+ stroke="currentColor"
28
+ viewBox="0 0 24 24"
29
+ >
30
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
31
+ </svg>
32
+ </button>
33
+ )
34
+ }
35
+
36
+ // Search input component
37
+ export function RelationSearchInput({
38
+ searchTerm,
39
+ onSearchChange,
40
+ relatedModelName
41
+ }: Readonly<{
42
+ searchTerm: string
43
+ onSearchChange: (value: string) => void
44
+ relatedModelName: string
45
+ }>) {
46
+ return (
47
+ <div className="p-2">
48
+ <input
49
+ type="text"
50
+ placeholder={`Search ${relatedModelName.toLowerCase()}...`}
51
+ value={searchTerm}
52
+ onChange={(e) => onSearchChange(e.target.value)}
53
+ className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-green-web"
54
+ autoFocus
55
+ />
56
+ </div>
57
+ )
58
+ }
59
+
60
+ // Individual item component
61
+ export function RelationItem({
62
+ item,
63
+ onSelect
64
+ }: {
65
+ item: any
66
+ onSelect: (item: any) => void
67
+ }) {
68
+ return (
69
+ <button
70
+ type="button"
71
+ onClick={() => onSelect(item)}
72
+ className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 focus:bg-gray-100 transition-colors"
73
+ >
74
+ {getItemDisplayName(item)}
75
+ </button>
76
+ )
77
+ }
78
+
79
+ // Item list component with error handling
80
+ export function RelationItemList({
81
+ items,
82
+ loading,
83
+ error,
84
+ onSelect,
85
+ onClear
86
+ }: Readonly<{
87
+ items: any[]
88
+ loading: boolean
89
+ error?: any
90
+ onSelect: (item: any) => void
91
+ onClear: () => void
92
+ }>) {
93
+ return (
94
+ <div className="max-h-48 overflow-y-auto">
95
+ <button
96
+ type="button"
97
+ onClick={onClear}
98
+ className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 text-gray-500 border-b border-gray-200"
99
+ >
100
+ Clear selection
101
+ </button>
102
+
103
+ {error ? (
104
+ <div className="px-3 py-2 text-sm text-red-600">
105
+ <div className="flex items-center">
106
+ <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
107
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
108
+ </svg>
109
+ Failed to load options
110
+ </div>
111
+ <div className="text-xs text-gray-500 mt-1">
112
+ {error.networkError ? 'Network error' : 'Please try again'}
113
+ </div>
114
+ </div>
115
+ ) : loading ? (
116
+ <div className="px-3 py-2 text-sm text-gray-500">
117
+ <div className="flex items-center">
118
+ <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
119
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
120
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
121
+ </svg>
122
+ Loading...
123
+ </div>
124
+ </div>
125
+ ) : items.length === 0 ? (
126
+ <div className="px-3 py-2 text-sm text-gray-500">No items found</div>
127
+ ) : (
128
+ items.map((item: any) => (
129
+ <RelationItem
130
+ key={item.id}
131
+ item={item}
132
+ onSelect={onSelect}
133
+ />
134
+ ))
135
+ )}
136
+ </div>
137
+ )
138
+ }
139
+
140
+ // Dropdown content container with error handling
141
+ export function RelationDropdownContent({
142
+ searchTerm,
143
+ onSearchChange,
144
+ relatedModelName,
145
+ items,
146
+ loading,
147
+ error,
148
+ onSelect,
149
+ onClear
150
+ }: {
151
+ searchTerm: string
152
+ onSearchChange: (value: string) => void
153
+ relatedModelName: string
154
+ items: any[]
155
+ loading: boolean
156
+ error?: any
157
+ onSelect: (item: any) => void
158
+ onClear: () => void
159
+ }) {
160
+ return (
161
+ <div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg">
162
+ <RelationSearchInput
163
+ searchTerm={searchTerm}
164
+ onSearchChange={onSearchChange}
165
+ relatedModelName={relatedModelName}
166
+ />
167
+ <RelationItemList
168
+ items={items}
169
+ loading={loading}
170
+ error={error}
171
+ onSelect={onSelect}
172
+ onClear={onClear}
173
+ />
174
+ </div>
175
+ )
176
+ }
@@ -0,0 +1,106 @@
1
+ import { useCallback, useMemo, useRef, useState } from 'react'
2
+ import { useClickOutside } from '../../hooks/useClickOutside'
3
+ import { useRelationData } from '../../hooks/useRelationData'
4
+ import { formatFieldName } from '../../utils/string-utils'
5
+ import { RelationDropdownButton, RelationDropdownContent } from './RelationComponents'
6
+
7
+ interface RelationFilterFieldProps {
8
+ fieldName: string
9
+ relatedModelName: string
10
+ currentValue: any
11
+ onChange: (value: any) => void
12
+ }
13
+
14
+ // Refactored relation field filtering component
15
+ export function RelationFilterField({
16
+ fieldName,
17
+ relatedModelName,
18
+ currentValue,
19
+ onChange
20
+ }: Readonly<RelationFilterFieldProps>) {
21
+ const [searchTerm, setSearchTerm] = useState('')
22
+ const [isOpen, setIsOpen] = useState(false)
23
+ const dropdownRef = useRef<HTMLDivElement>(null)
24
+
25
+ // Memoize the close handler to prevent memory leaks
26
+ const handleCloseDropdown = useCallback(() => {
27
+ setIsOpen(false)
28
+ }, [])
29
+
30
+ // Custom hooks for behavior
31
+ useClickOutside(dropdownRef, handleCloseDropdown, isOpen)
32
+ const { relatedItems, loading, error: relationError, hasDocument } = useRelationData(relatedModelName, searchTerm, isOpen)
33
+
34
+ // Find current item to display
35
+ const currentItem = useMemo(() =>
36
+ currentValue?.id ? relatedItems.find((item: any) => item.id === currentValue.id) : null,
37
+ [currentValue?.id, relatedItems]
38
+ )
39
+
40
+ // Event handlers
41
+ const handleSelect = useCallback((item: any) => {
42
+ onChange({ id: item.id })
43
+ setIsOpen(false)
44
+ setSearchTerm('') // Reset search when item selected
45
+ }, [onChange])
46
+
47
+ const handleClear = useCallback(() => {
48
+ onChange(undefined)
49
+ setIsOpen(false)
50
+ setSearchTerm('')
51
+ }, [onChange])
52
+
53
+ const handleToggleOpen = useCallback(() => {
54
+ setIsOpen(!isOpen)
55
+ if (!isOpen) {
56
+ setSearchTerm('') // Reset search when opening
57
+ }
58
+ }, [isOpen])
59
+
60
+ // If no document available, fall back to text input
61
+ if (!hasDocument) {
62
+ return (
63
+ <div className="space-y-1">
64
+ <label className="block text-sm font-medium text-gray-700">
65
+ {formatFieldName(fieldName)} ID
66
+ </label>
67
+ <input
68
+ type="text"
69
+ value={currentValue?.id || ''}
70
+ onChange={(e) => onChange(e.target.value ? { id: e.target.value } : undefined)}
71
+ placeholder="Enter ID..."
72
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-web focus:border-green-web text-sm"
73
+ />
74
+ </div>
75
+ )
76
+ }
77
+
78
+ return (
79
+ <div className="space-y-1" ref={dropdownRef}>
80
+ <label className="block text-sm font-medium text-gray-700">
81
+ {formatFieldName(fieldName)}
82
+ </label>
83
+ <div className="relative">
84
+ <RelationDropdownButton
85
+ currentItem={currentItem}
86
+ relatedModelName={relatedModelName}
87
+ isOpen={isOpen}
88
+ onClick={handleToggleOpen}
89
+ />
90
+
91
+ {isOpen && (
92
+ <RelationDropdownContent
93
+ searchTerm={searchTerm}
94
+ onSearchChange={setSearchTerm}
95
+ relatedModelName={relatedModelName}
96
+ items={relatedItems}
97
+ loading={loading}
98
+ error={relationError}
99
+ onSelect={handleSelect}
100
+ onClear={handleClear}
101
+ />
102
+ )}
103
+ </div>
104
+ </div>
105
+ )
106
+ }
@@ -0,0 +1,5 @@
1
+ export { DateRangeFilter } from './DateRangeFilter'
2
+ export { NumberRangeFilter } from './NumberRangeFilter'
3
+ export * from './RelationComponents'
4
+ export { RelationFilterField } from './RelationFilterField'
5
+
@@ -0,0 +1,2 @@
1
+ export * from './filters'
2
+ export * from './shared'
@@ -0,0 +1,88 @@
1
+ import React from 'react'
2
+ import { Link } from 'react-router'
3
+ import { ChevronRightIcon, HomeIcon } from '@heroicons/react/24/outline'
4
+
5
+ export interface BreadcrumbItem {
6
+ id: string
7
+ label: string
8
+ href?: string
9
+ isActive?: boolean
10
+ }
11
+
12
+ export interface AdminBreadcrumbsProps {
13
+ readonly items: readonly BreadcrumbItem[]
14
+ readonly showHome?: boolean
15
+ readonly homeHref?: string
16
+ readonly className?: string
17
+ }
18
+
19
+ export function AdminBreadcrumbs({
20
+ items,
21
+ showHome = true,
22
+ homeHref = '/admin',
23
+ className = '',
24
+ }: Readonly<AdminBreadcrumbsProps>) {
25
+ // Extract nested ternary logic into clear helper functions
26
+ const renderHomeLink = () => {
27
+ if (!showHome) return null
28
+
29
+ return (
30
+ <li key="home" className="flex items-center">
31
+ <Link
32
+ to={homeHref}
33
+ className="text-gray-400 hover:text-gray-500"
34
+ aria-label="Home"
35
+ >
36
+ <HomeIcon className="h-5 w-5 flex-shrink-0" />
37
+ </Link>
38
+ </li>
39
+ )
40
+ }
41
+
42
+ const renderBreadcrumbItem = (item: BreadcrumbItem, index: number, isLast: boolean) => {
43
+ const content = item.href && !item.isActive ? (
44
+ <Link
45
+ to={item.href}
46
+ className="text-sm font-medium text-gray-500 hover:text-gray-700"
47
+ >
48
+ {item.label}
49
+ </Link>
50
+ ) : (
51
+ <span
52
+ className={`text-sm font-medium ${
53
+ item.isActive ? 'text-gray-900' : 'text-gray-500'
54
+ }`}
55
+ >
56
+ {item.label}
57
+ </span>
58
+ )
59
+
60
+ return (
61
+ <li key={item.id} className="flex items-center">
62
+ {(showHome || index > 0) && (
63
+ <ChevronRightIcon
64
+ className="h-5 w-5 flex-shrink-0 text-gray-400 mr-4"
65
+ aria-hidden="true"
66
+ />
67
+ )}
68
+ {content}
69
+ </li>
70
+ )
71
+ }
72
+
73
+ const shouldShowSeparator = (index: number) => {
74
+ return showHome || index > 0
75
+ }
76
+
77
+ return (
78
+ <nav className={`flex ${className}`} aria-label="Breadcrumb">
79
+ <ol className="flex items-center space-x-4">
80
+ {renderHomeLink()}
81
+ {items.map((item, index) => {
82
+ const isLast = index === items.length - 1
83
+ return renderBreadcrumbItem(item, index, isLast)
84
+ })}
85
+ </ol>
86
+ </nav>
87
+ )
88
+ }
@@ -0,0 +1,180 @@
1
+ import React from 'react'
2
+ import { ExclamationTriangleIcon, ExclamationCircleIcon, XCircleIcon } from '@heroicons/react/24/outline'
3
+
4
+ export type ErrorSeverity = 'warning' | 'error' | 'critical'
5
+
6
+ export interface AdminErrorStateProps {
7
+ readonly title: string
8
+ readonly message?: string
9
+ readonly severity?: ErrorSeverity
10
+ readonly onRetry?: () => void
11
+ readonly onDismiss?: () => void
12
+ readonly className?: string
13
+ readonly showIcon?: boolean
14
+ }
15
+
16
+ export interface AdminEmptyStateProps {
17
+ readonly title: string
18
+ readonly message?: string
19
+ readonly actionLabel?: string
20
+ readonly onAction?: () => void
21
+ readonly className?: string
22
+ readonly icon?: React.ComponentType<{ className?: string }>
23
+ }
24
+
25
+ export interface AdminLoadingStateProps {
26
+ readonly title?: string
27
+ readonly message?: string
28
+ readonly className?: string
29
+ readonly size?: 'small' | 'medium' | 'large'
30
+ }
31
+
32
+ const getSeverityConfig = (severity: ErrorSeverity) => {
33
+ const configs = {
34
+ warning: {
35
+ icon: ExclamationTriangleIcon,
36
+ bgColor: 'bg-yellow-50',
37
+ borderColor: 'border-yellow-200',
38
+ iconColor: 'text-yellow-400',
39
+ titleColor: 'text-yellow-800',
40
+ messageColor: 'text-yellow-700',
41
+ buttonColor: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200',
42
+ },
43
+ error: {
44
+ icon: ExclamationCircleIcon,
45
+ bgColor: 'bg-red-50',
46
+ borderColor: 'border-red-200',
47
+ iconColor: 'text-red-400',
48
+ titleColor: 'text-red-800',
49
+ messageColor: 'text-red-700',
50
+ buttonColor: 'bg-red-100 text-red-800 hover:bg-red-200',
51
+ },
52
+ critical: {
53
+ icon: XCircleIcon,
54
+ bgColor: 'bg-red-100',
55
+ borderColor: 'border-red-300',
56
+ iconColor: 'text-red-500',
57
+ titleColor: 'text-red-900',
58
+ messageColor: 'text-red-800',
59
+ buttonColor: 'bg-red-200 text-red-900 hover:bg-red-300',
60
+ },
61
+ }
62
+ return configs[severity]
63
+ }
64
+
65
+ export function AdminErrorState({
66
+ title,
67
+ message,
68
+ severity = 'error',
69
+ onRetry,
70
+ onDismiss,
71
+ className = '',
72
+ showIcon = true,
73
+ }: Readonly<AdminErrorStateProps>) {
74
+ const config = getSeverityConfig(severity)
75
+ const IconComponent = config.icon
76
+
77
+ return (
78
+ <div className={`rounded-md border p-4 ${config.bgColor} ${config.borderColor} ${className}`}>
79
+ <div className="flex">
80
+ {showIcon && (
81
+ <div className="flex-shrink-0">
82
+ <IconComponent className={`h-5 w-5 ${config.iconColor}`} aria-hidden="true" />
83
+ </div>
84
+ )}
85
+ <div className={showIcon ? 'ml-3' : ''}>
86
+ <h3 className={`text-sm font-medium ${config.titleColor}`}>
87
+ {title}
88
+ </h3>
89
+ {message && (
90
+ <div className={`mt-2 text-sm ${config.messageColor}`}>
91
+ <p>{message}</p>
92
+ </div>
93
+ )}
94
+ {(onRetry || onDismiss) && (
95
+ <div className="mt-4">
96
+ <div className="-mx-2 -my-1.5 flex">
97
+ {onRetry && (
98
+ <button
99
+ type="button"
100
+ onClick={onRetry}
101
+ className={`rounded-md px-2 py-1.5 text-sm font-medium ${config.buttonColor} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500`}
102
+ >
103
+ Try Again
104
+ </button>
105
+ )}
106
+ {onDismiss && (
107
+ <button
108
+ type="button"
109
+ onClick={onDismiss}
110
+ className={`ml-3 rounded-md px-2 py-1.5 text-sm font-medium ${config.buttonColor} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500`}
111
+ >
112
+ Dismiss
113
+ </button>
114
+ )}
115
+ </div>
116
+ </div>
117
+ )}
118
+ </div>
119
+ </div>
120
+ </div>
121
+ )
122
+ }
123
+
124
+ export function AdminEmptyState({
125
+ title,
126
+ message,
127
+ actionLabel,
128
+ onAction,
129
+ className = '',
130
+ icon: IconComponent,
131
+ }: Readonly<AdminEmptyStateProps>) {
132
+ return (
133
+ <div className={`text-center ${className}`}>
134
+ {IconComponent && (
135
+ <IconComponent className="mx-auto h-12 w-12 text-gray-400" />
136
+ )}
137
+ <h3 className="mt-2 text-sm font-medium text-gray-900">{title}</h3>
138
+ {message && (
139
+ <p className="mt-1 text-sm text-gray-500">{message}</p>
140
+ )}
141
+ {actionLabel && onAction && (
142
+ <div className="mt-6">
143
+ <button
144
+ type="button"
145
+ onClick={onAction}
146
+ className="inline-flex items-center rounded-md bg-green-web px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-web-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-web"
147
+ >
148
+ {actionLabel}
149
+ </button>
150
+ </div>
151
+ )}
152
+ </div>
153
+ )
154
+ }
155
+
156
+ export function AdminLoadingState({
157
+ title = 'Loading...',
158
+ message,
159
+ className = '',
160
+ size = 'medium',
161
+ }: Readonly<AdminLoadingStateProps>) {
162
+ const getSizeClasses = () => {
163
+ const sizes = {
164
+ small: 'h-4 w-4',
165
+ medium: 'h-8 w-8',
166
+ large: 'h-12 w-12',
167
+ }
168
+ return sizes[size]
169
+ }
170
+
171
+ return (
172
+ <div className={`flex flex-col items-center justify-center p-8 ${className}`}>
173
+ <div className={`animate-spin rounded-full border-b-2 border-green-web ${getSizeClasses()}`}></div>
174
+ <h3 className="mt-4 text-sm font-medium text-gray-900">{title}</h3>
175
+ {message && (
176
+ <p className="mt-1 text-sm text-gray-500">{message}</p>
177
+ )}
178
+ </div>
179
+ )
180
+ }