@pattern-stack/frontend-patterns 0.0.3 → 0.0.4
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/index.es.js +1 -1
- package/dist/index.js +1 -0
- package/package.json +5 -3
- package/src/App.css +42 -0
- package/src/App.tsx +54 -0
- package/src/__tests__/README.md +221 -0
- package/src/__tests__/atoms/hooks/simple-hooks.test.ts +44 -0
- package/src/__tests__/atoms/ui/button.test.tsx +68 -0
- package/src/__tests__/atoms/utils/simple.test.ts +18 -0
- package/src/__tests__/atoms/utils/utils.test.ts +77 -0
- package/src/__tests__/features/auth/simple-auth.test.tsx +40 -0
- package/src/__tests__/molecules/layout/simple-layout.test.tsx +81 -0
- package/src/__tests__/organisms/showcase/simple-showcase.test.tsx +167 -0
- package/src/__tests__/setup.ts +51 -0
- package/src/__tests__/utils.tsx +123 -0
- package/src/atoms/composed/Accordion/Accordion.tsx +271 -0
- package/src/atoms/composed/Accordion/index.ts +1 -0
- package/src/atoms/composed/Alert/Alert.tsx +132 -0
- package/src/atoms/composed/Alert/index.ts +1 -0
- package/src/atoms/composed/Breadcrumb/Breadcrumb.tsx +83 -0
- package/src/atoms/composed/Breadcrumb/index.ts +1 -0
- package/src/atoms/composed/Chart/Chart.tsx +425 -0
- package/src/atoms/composed/Chart/index.ts +2 -0
- package/src/atoms/composed/ColorSwatch/ColorSwatch.tsx +72 -0
- package/src/atoms/composed/ColorSwatch/index.ts +1 -0
- package/src/atoms/composed/DarkModeToggle.tsx +66 -0
- package/src/atoms/composed/DataBadge/DataBadge.tsx +81 -0
- package/src/atoms/composed/DataBadge/index.ts +1 -0
- package/src/atoms/composed/DataTable/DataTable.tsx +394 -0
- package/src/atoms/composed/DataTable/TableCellWithTooltip.tsx +41 -0
- package/src/atoms/composed/DataTable/index.ts +2 -0
- package/src/atoms/composed/DateTimePicker/DateTimePicker.tsx +611 -0
- package/src/atoms/composed/DateTimePicker/index.ts +2 -0
- package/src/atoms/composed/DetailedCard/DetailedCard.tsx +181 -0
- package/src/atoms/composed/DetailedCard/index.ts +2 -0
- package/src/atoms/composed/EmptyState/EmptyState.tsx +90 -0
- package/src/atoms/composed/EmptyState/index.ts +1 -0
- package/src/atoms/composed/FileUpload/FileUpload.tsx +477 -0
- package/src/atoms/composed/FileUpload/index.ts +2 -0
- package/src/atoms/composed/FormField/FormField.tsx +92 -0
- package/src/atoms/composed/FormField/index.ts +1 -0
- package/src/atoms/composed/GlobalSearch/GlobalSearch.tsx +37 -0
- package/src/atoms/composed/GlobalSearch/index.ts +1 -0
- package/src/atoms/composed/IconBadge/IconBadge.tsx +95 -0
- package/src/atoms/composed/IconBadge/index.ts +2 -0
- package/src/atoms/composed/Modal/Modal.tsx +223 -0
- package/src/atoms/composed/Modal/index.ts +2 -0
- package/src/atoms/composed/PaletteSwitcher.tsx +386 -0
- package/src/atoms/composed/ProgressBar/ProgressBar.tsx +116 -0
- package/src/atoms/composed/ProgressBar/index.ts +1 -0
- package/src/atoms/composed/StatCard/StatCard.tsx +219 -0
- package/src/atoms/composed/StatCard/index.ts +1 -0
- package/src/atoms/composed/StyleGuide.tsx +717 -0
- package/src/atoms/composed/Toast/Toast.tsx +219 -0
- package/src/atoms/composed/Toast/index.ts +1 -0
- package/src/atoms/composed/Tooltip/Tooltip.tsx +213 -0
- package/src/atoms/composed/Tooltip/index.ts +1 -0
- package/src/atoms/composed/UserAvatar/UserAvatar.tsx +139 -0
- package/src/atoms/composed/UserAvatar/index.ts +1 -0
- package/src/atoms/composed/UserMenu/UserMenu.tsx +16 -0
- package/src/atoms/composed/UserMenu/index.ts +1 -0
- package/src/atoms/composed/index.ts +29 -0
- package/src/atoms/hooks/useApi.ts +80 -0
- package/src/atoms/hooks/useHealth.ts +17 -0
- package/src/atoms/index.ts +13 -0
- package/src/atoms/services/api/client.ts +134 -0
- package/src/atoms/services/auth-service.ts +248 -0
- package/src/atoms/services/health.ts +15 -0
- package/src/atoms/services/index.ts +3 -0
- package/src/atoms/shared/config/constants.ts +17 -0
- package/src/atoms/shared/config/dashboard-sizes.ts +111 -0
- package/src/atoms/shared/config/environment.ts +10 -0
- package/src/atoms/shared/index.ts +4 -0
- package/src/atoms/shared/styles/color-palettes.css +566 -0
- package/src/atoms/types/auth.ts +62 -0
- package/src/atoms/types/generated.ts +1469 -0
- package/src/atoms/types/index.ts +4 -0
- package/src/atoms/types/loading.ts +28 -0
- package/src/atoms/ui/Badge.tsx +30 -0
- package/src/atoms/ui/ErrorBoundary.tsx +59 -0
- package/src/atoms/ui/Select.tsx +53 -0
- package/src/atoms/ui/Switch.tsx +42 -0
- package/src/atoms/ui/Tabs.tsx +118 -0
- package/src/atoms/ui/avatar.tsx +48 -0
- package/src/atoms/ui/button.tsx +70 -0
- package/src/atoms/ui/card.tsx +76 -0
- package/src/atoms/ui/dropdown-menu.tsx +199 -0
- package/src/atoms/ui/index.ts +39 -0
- package/src/atoms/ui/input.tsx +23 -0
- package/src/atoms/ui/label.tsx +23 -0
- package/src/atoms/ui/skeleton.tsx +13 -0
- package/src/atoms/ui/spinner.tsx +49 -0
- package/src/atoms/ui/table.tsx +116 -0
- package/src/atoms/utils/animations.ts +135 -0
- package/src/atoms/utils/tooltip-helpers.ts +140 -0
- package/src/atoms/utils/utils.ts +9 -0
- package/src/features/auth/components/LoginForm.tsx +168 -0
- package/src/features/auth/components/LogoutButton.tsx +19 -0
- package/src/features/auth/components/ProtectedRoute.tsx +60 -0
- package/src/features/auth/components/index.ts +4 -0
- package/src/features/auth/hooks/index.ts +2 -0
- package/src/features/auth/hooks/useAuth.tsx +205 -0
- package/src/features/auth/hooks/usePermissions.ts +35 -0
- package/src/features/auth/index.ts +2 -0
- package/src/features/index.ts +2 -0
- package/src/index.css +704 -0
- package/src/index.ts +13 -0
- package/src/main.tsx +48 -0
- package/src/molecules/.gitkeep +0 -0
- package/src/molecules/forms/FormGroup.tsx +75 -0
- package/src/molecules/forms/SearchInput.tsx +259 -0
- package/src/molecules/forms/index.ts +4 -0
- package/src/molecules/index.ts +4 -0
- package/src/molecules/layout/AppHeader/AppHeader.tsx +42 -0
- package/src/molecules/layout/AppHeader/index.ts +1 -0
- package/src/molecules/layout/AppLayout.tsx +29 -0
- package/src/molecules/layout/PageTemplate.tsx +87 -0
- package/src/molecules/layout/SectionHeader/SectionHeader.tsx +87 -0
- package/src/molecules/layout/SectionHeader/index.ts +1 -0
- package/src/molecules/layout/ShowcaseSection.tsx +57 -0
- package/src/molecules/layout/Sidebar.tsx +144 -0
- package/src/molecules/layout/SidebarButton/SidebarButton.tsx +99 -0
- package/src/molecules/layout/SidebarButton/index.ts +1 -0
- package/src/molecules/layout/SidebarContext.tsx +31 -0
- package/src/molecules/layout/index.ts +7 -0
- package/src/molecules/navigation/NavMenu.tsx +188 -0
- package/src/molecules/navigation/Pagination.tsx +172 -0
- package/src/molecules/navigation/index.ts +4 -0
- package/src/organisms/index.ts +5 -0
- package/src/organisms/showcase/ComponentShowcasePage.tsx +2496 -0
- package/src/organisms/showcase/index.ts +1 -0
- package/src/pages/AdminShowcase/AdminCRUDShowcase.tsx +242 -0
- package/src/pages/AdminShowcase/AdminDashboardShowcase.tsx +171 -0
- package/src/pages/AdminShowcase/AdminDetailShowcase.tsx +385 -0
- package/src/pages/AdminShowcase/index.tsx +3 -0
- package/src/pages/ComponentShowcase/BadgesShowcase.tsx +188 -0
- package/src/pages/ComponentShowcase/CardsShowcase.tsx +392 -0
- package/src/pages/ComponentShowcase/PalettesShowcase.tsx +207 -0
- package/src/pages/ComponentShowcase/StatesShowcase.tsx +485 -0
- package/src/pages/ComponentShowcase/TablesShowcase.tsx +134 -0
- package/src/pages/ComponentShowcase/TypographyShowcase.tsx +255 -0
- package/src/pages/ComponentShowcase/index.tsx +188 -0
- package/src/pages/index.ts +2 -0
- package/src/templates/AuthTemplate.tsx +216 -0
- package/src/templates/ComponentShowcaseTemplate.tsx +173 -0
- package/src/templates/DashboardTemplate.tsx +232 -0
- package/src/templates/DataTemplate.tsx +319 -0
- package/src/templates/admin/AdminCRUDTemplate.tsx +630 -0
- package/src/templates/admin/AdminDashboardTemplate.tsx +351 -0
- package/src/templates/admin/AdminDetailTemplate.tsx +563 -0
- package/src/templates/admin/index.ts +29 -0
- package/src/templates/factory.tsx +169 -0
- package/src/templates/index.ts +37 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { cn } from '../../atoms/utils/utils';
|
|
3
|
+
import { IconBadge } from '../../atoms/composed';
|
|
4
|
+
import { DataTable, type Column } from '../../atoms/composed/DataTable';
|
|
5
|
+
import { DataBadge } from '../../atoms/composed/DataBadge';
|
|
6
|
+
import { Card } from '../../atoms/ui/card';
|
|
7
|
+
import { Button } from '../../atoms/ui/button';
|
|
8
|
+
import { Modal } from '../../atoms/composed/Modal';
|
|
9
|
+
import { FormField } from '../../atoms/composed/FormField';
|
|
10
|
+
import { EmptyState } from '../../atoms/composed/EmptyState';
|
|
11
|
+
import {
|
|
12
|
+
Plus,
|
|
13
|
+
Download,
|
|
14
|
+
Filter,
|
|
15
|
+
Edit,
|
|
16
|
+
Trash2,
|
|
17
|
+
Eye,
|
|
18
|
+
Upload,
|
|
19
|
+
RefreshCw
|
|
20
|
+
} from 'lucide-react';
|
|
21
|
+
|
|
22
|
+
export interface ResourceSchema {
|
|
23
|
+
/** Field definitions for the resource */
|
|
24
|
+
fields: ResourceField[];
|
|
25
|
+
/** Primary key field name */
|
|
26
|
+
primaryKey: string;
|
|
27
|
+
/** Display name for single resource */
|
|
28
|
+
displayName: string;
|
|
29
|
+
/** Display name for multiple resources */
|
|
30
|
+
displayNamePlural: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ResourceField {
|
|
34
|
+
/** Field name/key */
|
|
35
|
+
name: string;
|
|
36
|
+
/** Display label */
|
|
37
|
+
label: string;
|
|
38
|
+
/** Field type */
|
|
39
|
+
type: 'text' | 'email' | 'number' | 'boolean' | 'date' | 'select' | 'textarea' | 'file';
|
|
40
|
+
/** Whether field is required */
|
|
41
|
+
required?: boolean;
|
|
42
|
+
/** Field validation rules */
|
|
43
|
+
validation?: Record<string, unknown>;
|
|
44
|
+
/** Options for select fields */
|
|
45
|
+
options?: Array<{ value: string; label: string }>;
|
|
46
|
+
/** Whether field is searchable */
|
|
47
|
+
searchable?: boolean;
|
|
48
|
+
/** Whether field should be shown in table */
|
|
49
|
+
showInTable?: boolean;
|
|
50
|
+
/** Column width for table display */
|
|
51
|
+
width?: number;
|
|
52
|
+
/** Custom render function for table cells */
|
|
53
|
+
render?: (value: unknown, item: Record<string, unknown>) => React.ReactNode;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface CRUDPermissions {
|
|
57
|
+
/** Can view resources */
|
|
58
|
+
view: boolean;
|
|
59
|
+
/** Can create new resources */
|
|
60
|
+
create: boolean;
|
|
61
|
+
/** Can edit existing resources */
|
|
62
|
+
edit: boolean;
|
|
63
|
+
/** Can delete resources */
|
|
64
|
+
delete: boolean;
|
|
65
|
+
/** Can export data */
|
|
66
|
+
export: boolean;
|
|
67
|
+
/** Can import data */
|
|
68
|
+
import: boolean;
|
|
69
|
+
/** Can bulk edit */
|
|
70
|
+
bulkEdit: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CRUDActions {
|
|
74
|
+
/** Create new resource */
|
|
75
|
+
onCreate?: (data: Record<string, unknown>) => Promise<void>;
|
|
76
|
+
/** Update existing resource */
|
|
77
|
+
onUpdate?: (id: string, data: Record<string, unknown>) => Promise<void>;
|
|
78
|
+
/** Delete resource */
|
|
79
|
+
onDelete?: (id: string) => Promise<void>;
|
|
80
|
+
/** Bulk delete resources */
|
|
81
|
+
onBulkDelete?: (ids: string[]) => Promise<void>;
|
|
82
|
+
/** Export data */
|
|
83
|
+
onExport?: (format: 'csv' | 'xlsx' | 'json') => Promise<void>;
|
|
84
|
+
/** Import data */
|
|
85
|
+
onImport?: (file: File) => Promise<void>;
|
|
86
|
+
/** Refresh data */
|
|
87
|
+
onRefresh?: () => Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface AdminCRUDTemplateProps {
|
|
91
|
+
/** Resource schema definition */
|
|
92
|
+
schema: ResourceSchema;
|
|
93
|
+
/** Current data */
|
|
94
|
+
data: Record<string, unknown>[];
|
|
95
|
+
/** User permissions */
|
|
96
|
+
permissions: CRUDPermissions;
|
|
97
|
+
/** CRUD action handlers */
|
|
98
|
+
actions: CRUDActions;
|
|
99
|
+
/** Whether data is loading */
|
|
100
|
+
isLoading?: boolean;
|
|
101
|
+
/** Filter configuration */
|
|
102
|
+
filterConfig?: {
|
|
103
|
+
activeFilters?: Array<{ key: string; label: string; value: string }>;
|
|
104
|
+
onFilter?: () => void;
|
|
105
|
+
onClearFilters?: () => void;
|
|
106
|
+
};
|
|
107
|
+
/** Additional custom actions */
|
|
108
|
+
customActions?: Array<{
|
|
109
|
+
label: string;
|
|
110
|
+
icon?: React.ReactNode;
|
|
111
|
+
onClick: () => void;
|
|
112
|
+
variant?: 'default' | 'outline' | 'destructive';
|
|
113
|
+
}>;
|
|
114
|
+
/** Additional CSS classes */
|
|
115
|
+
className?: string;
|
|
116
|
+
/** Category for styling */
|
|
117
|
+
category?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
|
118
|
+
/** Custom empty state */
|
|
119
|
+
emptyState?: React.ReactNode;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const AdminCRUDTemplate: React.FC<AdminCRUDTemplateProps> = ({
|
|
123
|
+
schema,
|
|
124
|
+
data,
|
|
125
|
+
permissions,
|
|
126
|
+
actions,
|
|
127
|
+
isLoading = false,
|
|
128
|
+
filterConfig,
|
|
129
|
+
customActions = [],
|
|
130
|
+
className,
|
|
131
|
+
category = 1,
|
|
132
|
+
emptyState
|
|
133
|
+
}) => {
|
|
134
|
+
const [selectedItems, setSelectedItems] = useState<string[]>([]);
|
|
135
|
+
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
136
|
+
const [showEditModal, setShowEditModal] = useState(false);
|
|
137
|
+
const [editingItem, setEditingItem] = useState<Record<string, unknown> | null>(null);
|
|
138
|
+
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
|
139
|
+
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
140
|
+
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
141
|
+
|
|
142
|
+
const hasData = data && data.length > 0;
|
|
143
|
+
const hasSelection = selectedItems.length > 0;
|
|
144
|
+
const activeFilterCount = filterConfig?.activeFilters?.length || 0;
|
|
145
|
+
|
|
146
|
+
// Generate table columns from schema
|
|
147
|
+
const columns: Column<Record<string, unknown>>[] = [
|
|
148
|
+
// Selection column
|
|
149
|
+
{
|
|
150
|
+
key: 'select',
|
|
151
|
+
header: (
|
|
152
|
+
<input
|
|
153
|
+
type="checkbox"
|
|
154
|
+
checked={selectedItems.length > 0 && selectedItems.length === data.length}
|
|
155
|
+
onChange={(e) => {
|
|
156
|
+
if (e.target.checked) {
|
|
157
|
+
setSelectedItems(data.map(item => String(item[schema.primaryKey])));
|
|
158
|
+
} else {
|
|
159
|
+
setSelectedItems([]);
|
|
160
|
+
}
|
|
161
|
+
}}
|
|
162
|
+
className="rounded border-input"
|
|
163
|
+
/>
|
|
164
|
+
),
|
|
165
|
+
cell: (item) => (
|
|
166
|
+
<input
|
|
167
|
+
type="checkbox"
|
|
168
|
+
checked={selectedItems.includes(String(item[schema.primaryKey]))}
|
|
169
|
+
onChange={(e) => {
|
|
170
|
+
const id = String(item[schema.primaryKey]);
|
|
171
|
+
if (e.target.checked) {
|
|
172
|
+
setSelectedItems(prev => [...prev, id]);
|
|
173
|
+
} else {
|
|
174
|
+
setSelectedItems(prev => prev.filter(existingId => existingId !== id));
|
|
175
|
+
}
|
|
176
|
+
}}
|
|
177
|
+
className="rounded border-input"
|
|
178
|
+
/>
|
|
179
|
+
),
|
|
180
|
+
sortable: false,
|
|
181
|
+
width: '50px',
|
|
182
|
+
},
|
|
183
|
+
// Data columns
|
|
184
|
+
...schema.fields
|
|
185
|
+
.filter(field => field.showInTable !== false)
|
|
186
|
+
.map(field => {
|
|
187
|
+
// Determine column type for automatic badge rendering
|
|
188
|
+
let columnType: 'status' | 'category' | 'default' = 'default';
|
|
189
|
+
if (field.type === 'boolean' || field.name.toLowerCase().includes('status')) {
|
|
190
|
+
columnType = 'status';
|
|
191
|
+
} else if (field.name.toLowerCase().includes('category')) {
|
|
192
|
+
columnType = 'category';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
key: field.name,
|
|
197
|
+
header: field.label,
|
|
198
|
+
type: columnType,
|
|
199
|
+
cell: field.render ? (item: Record<string, unknown>) => field.render!(item[field.name], item) : undefined,
|
|
200
|
+
sortable: true,
|
|
201
|
+
width: field.width ? `${field.width}px` : undefined,
|
|
202
|
+
};
|
|
203
|
+
}),
|
|
204
|
+
// Actions column
|
|
205
|
+
{
|
|
206
|
+
key: 'actions',
|
|
207
|
+
header: 'Actions',
|
|
208
|
+
cell: (item) => (
|
|
209
|
+
<div className="flex items-center gap-1">
|
|
210
|
+
<Button
|
|
211
|
+
variant="ghost"
|
|
212
|
+
size="sm"
|
|
213
|
+
onClick={(e) => {
|
|
214
|
+
e.stopPropagation();
|
|
215
|
+
handleView(item);
|
|
216
|
+
}}
|
|
217
|
+
tooltip="View details"
|
|
218
|
+
>
|
|
219
|
+
<Eye className="w-4 h-4" />
|
|
220
|
+
</Button>
|
|
221
|
+
{permissions.edit && (
|
|
222
|
+
<Button
|
|
223
|
+
variant="ghost"
|
|
224
|
+
size="sm"
|
|
225
|
+
onClick={(e) => {
|
|
226
|
+
e.stopPropagation();
|
|
227
|
+
handleEdit(item);
|
|
228
|
+
}}
|
|
229
|
+
tooltip="Edit"
|
|
230
|
+
>
|
|
231
|
+
<Edit className="w-4 h-4" />
|
|
232
|
+
</Button>
|
|
233
|
+
)}
|
|
234
|
+
{permissions.delete && (
|
|
235
|
+
<Button
|
|
236
|
+
variant="ghost"
|
|
237
|
+
size="sm"
|
|
238
|
+
onClick={(e) => {
|
|
239
|
+
e.stopPropagation();
|
|
240
|
+
handleDelete(String(item[schema.primaryKey]));
|
|
241
|
+
}}
|
|
242
|
+
tooltip="Delete"
|
|
243
|
+
>
|
|
244
|
+
<Trash2 className="w-4 h-4" />
|
|
245
|
+
</Button>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
),
|
|
249
|
+
sortable: false,
|
|
250
|
+
width: '120px',
|
|
251
|
+
},
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
const handleCreate = () => {
|
|
255
|
+
setFormData({});
|
|
256
|
+
setShowCreateModal(true);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const handleEdit = (item: Record<string, unknown>) => {
|
|
260
|
+
setEditingItem(item);
|
|
261
|
+
setFormData(item);
|
|
262
|
+
setShowEditModal(true);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const handleView = (item: Record<string, unknown>) => {
|
|
266
|
+
// Navigate to detail view - this would be handled by the parent component
|
|
267
|
+
console.log('View item:', item);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const handleDelete = (id: string) => {
|
|
271
|
+
setDeletingId(id);
|
|
272
|
+
setShowDeleteConfirm(true);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const handleBulkDelete = () => {
|
|
276
|
+
if (actions.onBulkDelete) {
|
|
277
|
+
actions.onBulkDelete(selectedItems);
|
|
278
|
+
setSelectedItems([]);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const confirmDelete = async () => {
|
|
283
|
+
if (deletingId && actions.onDelete) {
|
|
284
|
+
await actions.onDelete(deletingId);
|
|
285
|
+
setShowDeleteConfirm(false);
|
|
286
|
+
setDeletingId(null);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const handleSubmit = async (isEdit: boolean) => {
|
|
291
|
+
try {
|
|
292
|
+
if (isEdit && editingItem && actions.onUpdate) {
|
|
293
|
+
await actions.onUpdate(String(editingItem[schema.primaryKey]), formData);
|
|
294
|
+
setShowEditModal(false);
|
|
295
|
+
} else if (!isEdit && actions.onCreate) {
|
|
296
|
+
await actions.onCreate(formData);
|
|
297
|
+
setShowCreateModal(false);
|
|
298
|
+
}
|
|
299
|
+
setFormData({});
|
|
300
|
+
setEditingItem(null);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
console.error('Form submission error:', error);
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const renderFormField = (field: ResourceField) => {
|
|
307
|
+
const value = formData[field.name];
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<FormField
|
|
311
|
+
key={field.name}
|
|
312
|
+
label={field.label}
|
|
313
|
+
required={field.required}
|
|
314
|
+
error={undefined} // Add validation error handling here
|
|
315
|
+
>
|
|
316
|
+
{field.type === 'select' ? (
|
|
317
|
+
<select
|
|
318
|
+
value={String(value || '')}
|
|
319
|
+
onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
|
|
320
|
+
className="w-full px-3 py-2 border border-input rounded-md"
|
|
321
|
+
>
|
|
322
|
+
<option value="">Select {field.label}</option>
|
|
323
|
+
{field.options?.map(option => (
|
|
324
|
+
<option key={option.value} value={option.value}>
|
|
325
|
+
{option.label}
|
|
326
|
+
</option>
|
|
327
|
+
))}
|
|
328
|
+
</select>
|
|
329
|
+
) : field.type === 'textarea' ? (
|
|
330
|
+
<textarea
|
|
331
|
+
value={String(value || '')}
|
|
332
|
+
onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
|
|
333
|
+
className="w-full px-3 py-2 border border-input rounded-md"
|
|
334
|
+
rows={3}
|
|
335
|
+
/>
|
|
336
|
+
) : field.type === 'boolean' ? (
|
|
337
|
+
<input
|
|
338
|
+
type="checkbox"
|
|
339
|
+
checked={Boolean(value)}
|
|
340
|
+
onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.checked }))}
|
|
341
|
+
className="rounded border-input"
|
|
342
|
+
/>
|
|
343
|
+
) : (
|
|
344
|
+
<input
|
|
345
|
+
type={field.type === 'email' ? 'email' : field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
|
|
346
|
+
value={String(value || '')}
|
|
347
|
+
onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
|
|
348
|
+
className="w-full px-3 py-2 border border-input rounded-md"
|
|
349
|
+
/>
|
|
350
|
+
)}
|
|
351
|
+
</FormField>
|
|
352
|
+
);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<div className={cn('flex flex-col min-h-0 flex-1', className)}>
|
|
357
|
+
{/* Header Section */}
|
|
358
|
+
<div className="bg-gradient-to-br from-muted/50 via-muted/30 to-background border-b border-border/50">
|
|
359
|
+
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12">
|
|
360
|
+
<div className="flex items-center justify-between">
|
|
361
|
+
<div className="flex items-center space-x-4">
|
|
362
|
+
<IconBadge
|
|
363
|
+
variant="category"
|
|
364
|
+
category={category}
|
|
365
|
+
size="lg"
|
|
366
|
+
icon={<Plus className="w-5 h-5" />}
|
|
367
|
+
/>
|
|
368
|
+
<div>
|
|
369
|
+
<h1 className="text-4xl font-bold text-foreground">{schema.displayNamePlural}</h1>
|
|
370
|
+
<p className="text-lg text-muted-foreground mt-2">
|
|
371
|
+
Manage {schema.displayNamePlural.toLowerCase()} with comprehensive CRUD operations
|
|
372
|
+
</p>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
<div className="flex items-center gap-2">
|
|
377
|
+
{actions.onRefresh && (
|
|
378
|
+
<Button
|
|
379
|
+
variant="outline"
|
|
380
|
+
onClick={actions.onRefresh}
|
|
381
|
+
disabled={isLoading}
|
|
382
|
+
>
|
|
383
|
+
<RefreshCw className={cn("w-4 h-4 mr-2", isLoading && "animate-spin")} />
|
|
384
|
+
Refresh
|
|
385
|
+
</Button>
|
|
386
|
+
)}
|
|
387
|
+
|
|
388
|
+
{permissions.create && (
|
|
389
|
+
<Button
|
|
390
|
+
onClick={handleCreate}
|
|
391
|
+
variant="default"
|
|
392
|
+
>
|
|
393
|
+
<Plus className="w-4 h-4 mr-2" />
|
|
394
|
+
Create {schema.displayName}
|
|
395
|
+
</Button>
|
|
396
|
+
)}
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
{/* Controls Section */}
|
|
403
|
+
<div className="flex-shrink-0 border-b border-border bg-muted/30">
|
|
404
|
+
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-4">
|
|
405
|
+
<div className="flex items-center justify-between">
|
|
406
|
+
{/* Left side - Filters */}
|
|
407
|
+
<div className="flex items-center gap-4">
|
|
408
|
+
{filterConfig && (
|
|
409
|
+
<Button
|
|
410
|
+
variant="outline"
|
|
411
|
+
onClick={filterConfig.onFilter}
|
|
412
|
+
className="relative"
|
|
413
|
+
>
|
|
414
|
+
<Filter className="w-4 h-4 mr-2" />
|
|
415
|
+
Filters
|
|
416
|
+
{activeFilterCount > 0 && (
|
|
417
|
+
<span className="absolute -top-2 -right-2 min-w-5 h-5 rounded-full text-xs bg-destructive text-destructive-foreground flex items-center justify-center">
|
|
418
|
+
{activeFilterCount}
|
|
419
|
+
</span>
|
|
420
|
+
)}
|
|
421
|
+
</Button>
|
|
422
|
+
)}
|
|
423
|
+
|
|
424
|
+
{/* Active Filters */}
|
|
425
|
+
{filterConfig?.activeFilters && filterConfig.activeFilters.length > 0 && (
|
|
426
|
+
<div className="flex items-center gap-2">
|
|
427
|
+
{filterConfig.activeFilters.map((filter, index) => (
|
|
428
|
+
<DataBadge key={index} variant="category" category={category} size="sm">
|
|
429
|
+
{filter.label}: {filter.value}
|
|
430
|
+
</DataBadge>
|
|
431
|
+
))}
|
|
432
|
+
<Button
|
|
433
|
+
variant="ghost"
|
|
434
|
+
size="sm"
|
|
435
|
+
onClick={filterConfig.onClearFilters}
|
|
436
|
+
>
|
|
437
|
+
Clear all
|
|
438
|
+
</Button>
|
|
439
|
+
</div>
|
|
440
|
+
)}
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
{/* Right side - Actions */}
|
|
444
|
+
<div className="flex items-center gap-2">
|
|
445
|
+
{/* Bulk actions */}
|
|
446
|
+
{hasSelection && (
|
|
447
|
+
<div className="flex items-center gap-2 mr-4">
|
|
448
|
+
<span className="text-sm text-muted-foreground">
|
|
449
|
+
{selectedItems.length} selected
|
|
450
|
+
</span>
|
|
451
|
+
{permissions.delete && (
|
|
452
|
+
<Button
|
|
453
|
+
variant="outline"
|
|
454
|
+
size="sm"
|
|
455
|
+
onClick={handleBulkDelete}
|
|
456
|
+
>
|
|
457
|
+
<Trash2 className="w-4 h-4 mr-2" />
|
|
458
|
+
Delete
|
|
459
|
+
</Button>
|
|
460
|
+
)}
|
|
461
|
+
</div>
|
|
462
|
+
)}
|
|
463
|
+
|
|
464
|
+
{/* Export/Import */}
|
|
465
|
+
{permissions.export && (
|
|
466
|
+
<Button
|
|
467
|
+
variant="outline"
|
|
468
|
+
onClick={() => actions.onExport?.('csv')}
|
|
469
|
+
>
|
|
470
|
+
<Download className="w-4 h-4 mr-2" />
|
|
471
|
+
Export
|
|
472
|
+
</Button>
|
|
473
|
+
)}
|
|
474
|
+
|
|
475
|
+
{permissions.import && (
|
|
476
|
+
<Button
|
|
477
|
+
variant="outline"
|
|
478
|
+
onClick={() => {
|
|
479
|
+
const input = document.createElement('input');
|
|
480
|
+
input.type = 'file';
|
|
481
|
+
input.accept = '.csv,.xlsx,.json';
|
|
482
|
+
input.onchange = (e) => {
|
|
483
|
+
const file = (e.target as HTMLInputElement).files?.[0];
|
|
484
|
+
if (file && actions.onImport) {
|
|
485
|
+
actions.onImport(file);
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
input.click();
|
|
489
|
+
}}
|
|
490
|
+
>
|
|
491
|
+
<Upload className="w-4 h-4 mr-2" />
|
|
492
|
+
Import
|
|
493
|
+
</Button>
|
|
494
|
+
)}
|
|
495
|
+
|
|
496
|
+
{/* Custom Actions */}
|
|
497
|
+
{customActions.map((action, index) => (
|
|
498
|
+
<Button
|
|
499
|
+
key={index}
|
|
500
|
+
variant={action.variant || 'outline'}
|
|
501
|
+
onClick={action.onClick}
|
|
502
|
+
>
|
|
503
|
+
{action.icon}
|
|
504
|
+
{action.label}
|
|
505
|
+
</Button>
|
|
506
|
+
))}
|
|
507
|
+
</div>
|
|
508
|
+
</div>
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
|
|
512
|
+
{/* Main Content */}
|
|
513
|
+
<div className="flex-1 min-h-0 overflow-auto">
|
|
514
|
+
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
|
515
|
+
{hasData ? (
|
|
516
|
+
<Card className="p-6" category={category}>
|
|
517
|
+
<DataTable
|
|
518
|
+
data={data}
|
|
519
|
+
columns={columns}
|
|
520
|
+
searchPlaceholder={`Search ${schema.displayNamePlural.toLowerCase()}...`}
|
|
521
|
+
isLoading={isLoading}
|
|
522
|
+
hover={true}
|
|
523
|
+
showSearch={true}
|
|
524
|
+
showPagination={true}
|
|
525
|
+
pageSize={10}
|
|
526
|
+
onRowClick={permissions.view ? handleView : undefined}
|
|
527
|
+
/>
|
|
528
|
+
</Card>
|
|
529
|
+
) : (
|
|
530
|
+
<div className="flex items-center justify-center min-h-96">
|
|
531
|
+
{emptyState || (
|
|
532
|
+
<EmptyState
|
|
533
|
+
title={`No ${schema.displayNamePlural.toLowerCase()} found`}
|
|
534
|
+
description={`Get started by creating your first ${schema.displayName.toLowerCase()}.`}
|
|
535
|
+
action={
|
|
536
|
+
permissions.create ? {
|
|
537
|
+
label: `Create ${schema.displayName}`,
|
|
538
|
+
onClick: handleCreate
|
|
539
|
+
} : undefined
|
|
540
|
+
}
|
|
541
|
+
/>
|
|
542
|
+
)}
|
|
543
|
+
</div>
|
|
544
|
+
)}
|
|
545
|
+
</div>
|
|
546
|
+
</div>
|
|
547
|
+
|
|
548
|
+
{/* Create Modal */}
|
|
549
|
+
<Modal
|
|
550
|
+
isOpen={showCreateModal}
|
|
551
|
+
onClose={() => setShowCreateModal(false)}
|
|
552
|
+
title={`Create ${schema.displayName}`}
|
|
553
|
+
size="lg"
|
|
554
|
+
>
|
|
555
|
+
<div className="space-y-4">
|
|
556
|
+
{schema.fields.map(renderFormField)}
|
|
557
|
+
|
|
558
|
+
<div className="flex justify-end gap-2 pt-4">
|
|
559
|
+
<Button
|
|
560
|
+
variant="outline"
|
|
561
|
+
onClick={() => setShowCreateModal(false)}
|
|
562
|
+
>
|
|
563
|
+
Cancel
|
|
564
|
+
</Button>
|
|
565
|
+
<Button
|
|
566
|
+
onClick={() => handleSubmit(false)}
|
|
567
|
+
variant="default"
|
|
568
|
+
>
|
|
569
|
+
Create
|
|
570
|
+
</Button>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
</Modal>
|
|
574
|
+
|
|
575
|
+
{/* Edit Modal */}
|
|
576
|
+
<Modal
|
|
577
|
+
isOpen={showEditModal}
|
|
578
|
+
onClose={() => setShowEditModal(false)}
|
|
579
|
+
title={`Edit ${schema.displayName}`}
|
|
580
|
+
size="lg"
|
|
581
|
+
>
|
|
582
|
+
<div className="space-y-4">
|
|
583
|
+
{schema.fields.map(renderFormField)}
|
|
584
|
+
|
|
585
|
+
<div className="flex justify-end gap-2 pt-4">
|
|
586
|
+
<Button
|
|
587
|
+
variant="outline"
|
|
588
|
+
onClick={() => setShowEditModal(false)}
|
|
589
|
+
>
|
|
590
|
+
Cancel
|
|
591
|
+
</Button>
|
|
592
|
+
<Button
|
|
593
|
+
onClick={() => handleSubmit(true)}
|
|
594
|
+
variant="default"
|
|
595
|
+
>
|
|
596
|
+
Update
|
|
597
|
+
</Button>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
</Modal>
|
|
601
|
+
|
|
602
|
+
{/* Delete Confirmation Modal */}
|
|
603
|
+
<Modal
|
|
604
|
+
isOpen={showDeleteConfirm}
|
|
605
|
+
onClose={() => setShowDeleteConfirm(false)}
|
|
606
|
+
title="Confirm Delete"
|
|
607
|
+
size="sm"
|
|
608
|
+
>
|
|
609
|
+
<div className="space-y-4">
|
|
610
|
+
<p>Are you sure you want to delete this {schema.displayName.toLowerCase()}? This action cannot be undone.</p>
|
|
611
|
+
|
|
612
|
+
<div className="flex justify-end gap-2">
|
|
613
|
+
<Button
|
|
614
|
+
variant="outline"
|
|
615
|
+
onClick={() => setShowDeleteConfirm(false)}
|
|
616
|
+
>
|
|
617
|
+
Cancel
|
|
618
|
+
</Button>
|
|
619
|
+
<Button
|
|
620
|
+
variant="destructive"
|
|
621
|
+
onClick={confirmDelete}
|
|
622
|
+
>
|
|
623
|
+
Delete
|
|
624
|
+
</Button>
|
|
625
|
+
</div>
|
|
626
|
+
</div>
|
|
627
|
+
</Modal>
|
|
628
|
+
</div>
|
|
629
|
+
);
|
|
630
|
+
};
|