@elsapiens/cli 0.1.0 → 0.1.2
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/README.md +115 -54
- package/dist/index.js +109 -36
- package/dist/index.js.map +1 -1
- package/dist/templates/app/app.ejs +295 -13
- package/dist/templates/app/env-example.ejs +3 -0
- package/dist/templates/app/eslint-config.ejs +47 -0
- package/dist/templates/app/help-topics.ejs +135 -0
- package/dist/templates/app/husky-pre-commit.ejs +8 -0
- package/dist/templates/app/index-css.ejs +536 -1
- package/dist/templates/app/main.ejs +81 -4
- package/dist/templates/app/package.ejs +28 -3
- package/dist/templates/app/page-dashboard.ejs +99 -60
- package/dist/templates/app/page-settings.ejs +268 -91
- package/dist/templates/app/postcss-config.ejs +6 -0
- package/dist/templates/app/services-setup.ejs +158 -0
- package/dist/templates/app/tailwind-config.ejs +105 -0
- package/dist/templates/app/test-setup.ejs +1 -0
- package/dist/templates/app/translations-common-en.ejs +23 -0
- package/dist/templates/app/translations-common-index.ejs +18 -0
- package/dist/templates/app/translations-common.ejs +94 -0
- package/dist/templates/app/translations-settings-en.ejs +49 -0
- package/dist/templates/app/translations-settings-index.ejs +18 -0
- package/dist/templates/app/tsconfig-node.ejs +10 -0
- package/dist/templates/app/vite-env.ejs +13 -0
- package/dist/templates/app/vitest-config.ejs +30 -0
- package/dist/templates/pages/list.ejs +636 -165
- package/dist/templates/pages/settings.ejs +208 -136
- package/package.json +4 -2
|
@@ -1,191 +1,662 @@
|
|
|
1
|
+
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
2
|
+
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
|
3
|
+
import { cn } from '@elsapiens/utils';
|
|
4
|
+
import { usePageHeader } from '@elsapiens/providers';
|
|
5
|
+
import { PageHeaderWithBreadcrumbs } from '@elsapiens/layout';
|
|
1
6
|
import {
|
|
2
7
|
Button,
|
|
3
|
-
Input,
|
|
4
|
-
Select,
|
|
5
|
-
Card,
|
|
6
|
-
CardHeader,
|
|
7
|
-
CardTitle,
|
|
8
|
-
CardContent,
|
|
9
|
-
Table,
|
|
10
8
|
Badge,
|
|
11
|
-
|
|
9
|
+
Table,
|
|
10
|
+
FilterBar,
|
|
11
|
+
Skeleton,
|
|
12
|
+
Modal,
|
|
13
|
+
Summary,
|
|
12
14
|
} from '@elsapiens/ui';
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
// Sample data - replace with your data source
|
|
16
|
-
const items = [
|
|
17
|
-
{ id: '1', name: 'Item One', type: 'Type A', status: 'active', date: '2024-01-15' },
|
|
18
|
-
{ id: '2', name: 'Item Two', type: 'Type B', status: 'pending', date: '2024-01-14' },
|
|
19
|
-
{ id: '3', name: 'Item Three', type: 'Type A', status: 'active', date: '2024-01-13' },
|
|
20
|
-
{ id: '4', name: 'Item Four', type: 'Type C', status: 'inactive', date: '2024-01-12' },
|
|
21
|
-
{ id: '5', name: 'Item Five', type: 'Type B', status: 'active', date: '2024-01-11' },
|
|
22
|
-
{ id: '6', name: 'Item Six', type: 'Type A', status: 'pending', date: '2024-01-10' },
|
|
23
|
-
{ id: '7', name: 'Item Seven', type: 'Type C', status: 'active', date: '2024-01-09' },
|
|
24
|
-
{ id: '8', name: 'Item Eight', type: 'Type B', status: 'inactive', date: '2024-01-08' },
|
|
25
|
-
];
|
|
15
|
+
import type { TableColumn, FilterBarTab, FilterBarField, FilterBarCondition } from '@elsapiens/ui';
|
|
16
|
+
import { Plus, Edit, Trash2, SearchX, <%= icon %>, Info, ChevronDown, ChevronUp, Tag, BarChart3, Layers, Settings, TrendingUp, AlertTriangle } from 'lucide-react';
|
|
26
17
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
18
|
+
interface <%= pascalName %>ListPageProps {
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Table row type - customize based on your data
|
|
23
|
+
interface <%= pascalName %>Row extends Record<string, unknown> {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
status: 'active' | 'draft' | 'archived';
|
|
27
|
+
category: string;
|
|
28
|
+
createdAt: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Sample data - replace with your data source (e.g., GraphQL query)
|
|
32
|
+
const sampleData: <%= pascalName %>Row[] = [
|
|
33
|
+
{ id: '1', name: '<%= singularTitle %> One', status: 'active', category: 'Category A', createdAt: '2024-01-15' },
|
|
34
|
+
{ id: '2', name: '<%= singularTitle %> Two', status: 'active', category: 'Category B', createdAt: '2024-01-14' },
|
|
35
|
+
{ id: '3', name: '<%= singularTitle %> Three', status: 'draft', category: 'Category A', createdAt: '2024-01-13' },
|
|
36
|
+
{ id: '4', name: '<%= singularTitle %> Four', status: 'archived', category: 'Category C', createdAt: '2024-01-12' },
|
|
37
|
+
{ id: '5', name: '<%= singularTitle %> Five', status: 'active', category: 'Category B', createdAt: '2024-01-11' },
|
|
31
38
|
];
|
|
32
39
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
case 'pending':
|
|
38
|
-
return 'warning';
|
|
39
|
-
case 'inactive':
|
|
40
|
-
return 'default';
|
|
41
|
-
default:
|
|
42
|
-
return 'default';
|
|
43
|
-
}
|
|
40
|
+
const statusLabels: Record<string, string> = {
|
|
41
|
+
active: 'Active',
|
|
42
|
+
draft: 'Draft',
|
|
43
|
+
archived: 'Archived',
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
46
|
+
const statusColors: Record<string, string> = {
|
|
47
|
+
active: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
|
48
|
+
draft: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
|
49
|
+
archived: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const categoryOptions = [
|
|
53
|
+
{ value: 'Category A', label: 'Category A' },
|
|
54
|
+
{ value: 'Category B', label: 'Category B' },
|
|
55
|
+
{ value: 'Category C', label: 'Category C' },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
export function <%= pascalName %>ListPage({ className }: <%= pascalName %>ListPageProps) {
|
|
59
|
+
const navigate = useNavigate();
|
|
60
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
61
|
+
|
|
62
|
+
// URL-based state
|
|
63
|
+
const activeTab = searchParams.get('tab') || 'all';
|
|
64
|
+
const searchValue = searchParams.get('search') || '';
|
|
65
|
+
const page = parseInt(searchParams.get('page') || '1', 10);
|
|
66
|
+
const sortBy = searchParams.get('sortBy') || 'name';
|
|
67
|
+
const sortOrder = (searchParams.get('sortOrder') || 'asc') as 'asc' | 'desc';
|
|
68
|
+
|
|
69
|
+
// Filter conditions state (synced with URL)
|
|
70
|
+
const [conditions, setConditions] = useState<FilterBarCondition[]>([]);
|
|
71
|
+
|
|
72
|
+
// Initialize conditions from URL params on mount
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const categoryParam = searchParams.get('category');
|
|
75
|
+
if (categoryParam) {
|
|
76
|
+
// Support comma-separated values for multi-select
|
|
77
|
+
const categoryValues = categoryParam.split(',').filter(Boolean);
|
|
78
|
+
setConditions([{
|
|
79
|
+
id: 'category-filter',
|
|
80
|
+
fieldId: 'category',
|
|
81
|
+
operator: 'equals',
|
|
82
|
+
value: categoryValues,
|
|
83
|
+
}]);
|
|
84
|
+
}
|
|
85
|
+
}, []); // Only run on mount
|
|
86
|
+
|
|
87
|
+
// Modal state
|
|
88
|
+
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
|
89
|
+
const [itemToDelete, setItemToDelete] = useState<<%= pascalName %>Row | null>(null);
|
|
90
|
+
|
|
91
|
+
// Loading state - set to true when fetching data
|
|
92
|
+
const [loading] = useState(false);
|
|
93
|
+
|
|
94
|
+
// Collapsible info state (persisted in localStorage)
|
|
95
|
+
const [isInfoCollapsed, setIsInfoCollapsed] = useState(() => {
|
|
96
|
+
if (typeof window !== 'undefined') {
|
|
97
|
+
return localStorage.getItem('<%= kebabName %>-list-info-collapsed') === 'true';
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const toggleInfoCollapsed = useCallback(() => {
|
|
103
|
+
setIsInfoCollapsed(prev => {
|
|
104
|
+
const newValue = !prev;
|
|
105
|
+
localStorage.setItem('<%= kebabName %>-list-info-collapsed', String(newValue));
|
|
106
|
+
return newValue;
|
|
107
|
+
});
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
// Handle conditions change - sync to URL params
|
|
111
|
+
const handleConditionsChange = useCallback((newConditions: FilterBarCondition[]) => {
|
|
112
|
+
setConditions(newConditions);
|
|
113
|
+
const params = new URLSearchParams(searchParams);
|
|
114
|
+
|
|
115
|
+
// Clear old filter params
|
|
116
|
+
params.delete('category');
|
|
117
|
+
|
|
118
|
+
// Add new filter params from conditions (support multiple values)
|
|
119
|
+
newConditions.forEach(condition => {
|
|
120
|
+
if (condition.fieldId === 'category' && Array.isArray(condition.value) && condition.value.length > 0) {
|
|
121
|
+
// Store multiple values as comma-separated string
|
|
122
|
+
params.set('category', condition.value.join(','));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
params.delete('page'); // Reset to first page when filters change
|
|
127
|
+
setSearchParams(params);
|
|
128
|
+
}, [searchParams, setSearchParams]);
|
|
129
|
+
|
|
130
|
+
// Extract category filter from conditions (returns array for multi-select)
|
|
131
|
+
const categoryFilters = useMemo(() => {
|
|
132
|
+
const categoryCondition = conditions.find(c => c.fieldId === 'category');
|
|
133
|
+
if (categoryCondition && Array.isArray(categoryCondition.value) && categoryCondition.value.length > 0) {
|
|
134
|
+
return categoryCondition.value;
|
|
135
|
+
}
|
|
136
|
+
return [];
|
|
137
|
+
}, [conditions]);
|
|
138
|
+
|
|
139
|
+
// Filter data based on URL params and conditions
|
|
140
|
+
const filteredData = useMemo(() => {
|
|
141
|
+
let result = [...sampleData];
|
|
142
|
+
|
|
143
|
+
// Filter by search
|
|
144
|
+
if (searchValue) {
|
|
145
|
+
const search = searchValue.toLowerCase();
|
|
146
|
+
result = result.filter(item =>
|
|
147
|
+
item.name.toLowerCase().includes(search)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Filter by category (from conditions - supports multiple selections)
|
|
152
|
+
if (categoryFilters.length > 0) {
|
|
153
|
+
result = result.filter(item => categoryFilters.includes(item.category));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Filter by tab/status
|
|
157
|
+
if (activeTab === 'active') {
|
|
158
|
+
result = result.filter(item => item.status === 'active');
|
|
159
|
+
} else if (activeTab === 'draft') {
|
|
160
|
+
result = result.filter(item => item.status === 'draft');
|
|
161
|
+
} else if (activeTab === 'archived') {
|
|
162
|
+
result = result.filter(item => item.status === 'archived');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Sort
|
|
166
|
+
result.sort((a, b) => {
|
|
167
|
+
const aVal = String(a[sortBy] || '');
|
|
168
|
+
const bVal = String(b[sortBy] || '');
|
|
169
|
+
return sortOrder === 'asc'
|
|
170
|
+
? aVal.localeCompare(bVal)
|
|
171
|
+
: bVal.localeCompare(aVal);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return result;
|
|
175
|
+
}, [searchValue, categoryFilters, activeTab, sortBy, sortOrder]);
|
|
176
|
+
|
|
177
|
+
// Pagination
|
|
178
|
+
const perPage = 20;
|
|
179
|
+
const totalPages = Math.ceil(filteredData.length / perPage);
|
|
180
|
+
const paginatedData = filteredData.slice((page - 1) * perPage, page * perPage);
|
|
181
|
+
|
|
182
|
+
// Check if any filters are active
|
|
183
|
+
const hasActiveFilters = Boolean(searchValue || conditions.length > 0 || activeTab !== 'all');
|
|
184
|
+
|
|
185
|
+
// Summary stats
|
|
186
|
+
const summaryStats = useMemo(() => {
|
|
187
|
+
const total = sampleData.length;
|
|
188
|
+
const active = sampleData.filter(i => i.status === 'active').length;
|
|
189
|
+
const draft = sampleData.filter(i => i.status === 'draft').length;
|
|
190
|
+
const archived = sampleData.filter(i => i.status === 'archived').length;
|
|
191
|
+
return { total, active, draft, archived };
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
194
|
+
// Helper to clear all filters
|
|
195
|
+
const clearFilters = useCallback(() => {
|
|
196
|
+
setConditions([]);
|
|
197
|
+
setSearchParams(new URLSearchParams());
|
|
198
|
+
}, [setSearchParams]);
|
|
199
|
+
|
|
200
|
+
// Set page header
|
|
201
|
+
usePageHeader({
|
|
202
|
+
title: '<%= title %>',
|
|
203
|
+
description: '<%= description %>',
|
|
204
|
+
breadcrumbs: [
|
|
205
|
+
{ label: 'Home', href: '/', as: Link },
|
|
206
|
+
{ label: '<%= title %>' },
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Filter tabs
|
|
211
|
+
const filterTabs: FilterBarTab[] = [
|
|
212
|
+
{ id: 'all', label: 'All', count: sampleData.length },
|
|
213
|
+
{ id: 'active', label: 'Active', count: sampleData.filter(i => i.status === 'active').length },
|
|
214
|
+
{ id: 'draft', label: 'Draft', count: sampleData.filter(i => i.status === 'draft').length },
|
|
215
|
+
{ id: 'archived', label: 'Archived', count: sampleData.filter(i => i.status === 'archived').length },
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
// Filter fields
|
|
219
|
+
const filterFields: FilterBarField[] = [
|
|
220
|
+
{
|
|
221
|
+
id: 'category',
|
|
222
|
+
label: 'Category',
|
|
223
|
+
type: 'multi-select',
|
|
224
|
+
options: categoryOptions,
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
// Handlers
|
|
229
|
+
const handleTabChange = useCallback((tabId: string) => {
|
|
230
|
+
const params = new URLSearchParams(searchParams);
|
|
231
|
+
params.set('tab', tabId);
|
|
232
|
+
params.delete('page');
|
|
233
|
+
setSearchParams(params);
|
|
234
|
+
}, [searchParams, setSearchParams]);
|
|
235
|
+
|
|
236
|
+
const handleSearchChange = useCallback((value: string) => {
|
|
237
|
+
const params = new URLSearchParams(searchParams);
|
|
238
|
+
if (value) {
|
|
239
|
+
params.set('search', value);
|
|
240
|
+
} else {
|
|
241
|
+
params.delete('search');
|
|
242
|
+
}
|
|
243
|
+
params.delete('page');
|
|
244
|
+
setSearchParams(params);
|
|
245
|
+
}, [searchParams, setSearchParams]);
|
|
246
|
+
|
|
247
|
+
const handlePageChange = useCallback((newPage: number) => {
|
|
248
|
+
const params = new URLSearchParams(searchParams);
|
|
249
|
+
params.set('page', newPage.toString());
|
|
250
|
+
setSearchParams(params);
|
|
251
|
+
}, [searchParams, setSearchParams]);
|
|
252
|
+
|
|
253
|
+
const handleDelete = useCallback((item: <%= pascalName %>Row) => {
|
|
254
|
+
setItemToDelete(item);
|
|
255
|
+
setDeleteModalOpen(true);
|
|
256
|
+
}, []);
|
|
257
|
+
|
|
258
|
+
const confirmDelete = useCallback(async () => {
|
|
259
|
+
if (!itemToDelete) return;
|
|
260
|
+
// TODO: Call your delete mutation/API here
|
|
261
|
+
console.log('Deleting:', itemToDelete.id);
|
|
262
|
+
setDeleteModalOpen(false);
|
|
263
|
+
setItemToDelete(null);
|
|
264
|
+
}, [itemToDelete]);
|
|
265
|
+
|
|
266
|
+
// Table columns
|
|
267
|
+
const columns: TableColumn<<%= pascalName %>Row>[] = [
|
|
268
|
+
{
|
|
269
|
+
key: 'name',
|
|
270
|
+
header: 'Name',
|
|
271
|
+
render: (item: <%= pascalName %>Row) => (
|
|
272
|
+
<div className="flex items-center el-gap-sm">
|
|
273
|
+
<div className="w-8 h-8 rounded bg-primary/10 flex items-center justify-center flex-shrink-0">
|
|
274
|
+
<<%= icon %> className="w-4 h-4 text-primary" />
|
|
275
|
+
</div>
|
|
276
|
+
<div className="min-w-0">
|
|
277
|
+
<div className="font-medium truncate">{item.name}</div>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
),
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
key: 'category',
|
|
284
|
+
header: 'Category',
|
|
285
|
+
render: (item: <%= pascalName %>Row) => (
|
|
286
|
+
<Badge variant="outline" size="sm">
|
|
287
|
+
{item.category}
|
|
288
|
+
</Badge>
|
|
289
|
+
),
|
|
290
|
+
width: '120px',
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
key: 'status',
|
|
294
|
+
header: 'Status',
|
|
295
|
+
render: (item: <%= pascalName %>Row) => (
|
|
296
|
+
<Badge className={cn('text-xs', statusColors[item.status])}>
|
|
297
|
+
{statusLabels[item.status]}
|
|
298
|
+
</Badge>
|
|
299
|
+
),
|
|
300
|
+
width: '100px',
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
key: 'createdAt',
|
|
304
|
+
header: 'Created',
|
|
305
|
+
render: (item: <%= pascalName %>Row) => (
|
|
306
|
+
<span className="text-sm text-muted-foreground">{item.createdAt}</span>
|
|
307
|
+
),
|
|
308
|
+
width: '120px',
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
key: 'actions',
|
|
312
|
+
header: '',
|
|
313
|
+
render: (item: <%= pascalName %>Row) => (
|
|
314
|
+
<div className="flex items-center el-gap-xs justify-end">
|
|
315
|
+
<Button
|
|
316
|
+
variant="ghost"
|
|
317
|
+
size="sm"
|
|
318
|
+
onClick={(e) => {
|
|
319
|
+
e.stopPropagation();
|
|
320
|
+
navigate(`/<%= routePath %>/${item.id}/edit`);
|
|
321
|
+
}}
|
|
322
|
+
>
|
|
323
|
+
<Edit className="w-4 h-4" />
|
|
324
|
+
</Button>
|
|
325
|
+
<Button
|
|
326
|
+
variant="ghost"
|
|
327
|
+
size="sm"
|
|
328
|
+
onClick={(e) => {
|
|
329
|
+
e.stopPropagation();
|
|
330
|
+
handleDelete(item);
|
|
331
|
+
}}
|
|
332
|
+
>
|
|
333
|
+
<Trash2 className="w-4 h-4 text-destructive" />
|
|
334
|
+
</Button>
|
|
54
335
|
</div>
|
|
55
|
-
|
|
336
|
+
),
|
|
337
|
+
width: '100px',
|
|
338
|
+
},
|
|
339
|
+
];
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
<div className={cn('flex flex-col flex-1', className)}>
|
|
343
|
+
<PageHeaderWithBreadcrumbs variant="card" linkComponent={Link}>
|
|
344
|
+
<Button onClick={() => navigate('/<%= routePath %>/new')}>
|
|
56
345
|
<Plus className="w-4 h-4 mr-2" />
|
|
57
|
-
Add
|
|
346
|
+
Add <%= singularTitle %>
|
|
58
347
|
</Button>
|
|
59
|
-
</
|
|
348
|
+
</PageHeaderWithBreadcrumbs>
|
|
349
|
+
|
|
350
|
+
<div className="flex-1 flex flex-col el-p-lg el-gap-lg" style={{ overflowX: 'clip' }}>
|
|
351
|
+
{/* Summary Bar */}
|
|
352
|
+
<Summary
|
|
353
|
+
items={[
|
|
354
|
+
{
|
|
355
|
+
label: 'Total <%= title %>',
|
|
356
|
+
value: summaryStats.total,
|
|
357
|
+
icon: <<%= icon %> className="w-4 h-4" />,
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
label: 'Active',
|
|
361
|
+
value: summaryStats.active,
|
|
362
|
+
icon: <TrendingUp className="w-4 h-4" />,
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
label: 'Draft',
|
|
366
|
+
value: summaryStats.draft,
|
|
367
|
+
icon: <AlertTriangle className="w-4 h-4" />,
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
label: 'Archived',
|
|
371
|
+
value: summaryStats.archived,
|
|
372
|
+
icon: <Layers className="w-4 h-4" />,
|
|
373
|
+
},
|
|
374
|
+
]}
|
|
375
|
+
/>
|
|
376
|
+
|
|
377
|
+
{/* Main Content Card */}
|
|
378
|
+
<div className="flex-1 flex flex-col border rounded-lg el-p-md" style={{ '--el-container-px': 'var(--el-layout-gap-md)' } as React.CSSProperties}>
|
|
379
|
+
<FilterBar
|
|
380
|
+
tabs={filterTabs}
|
|
381
|
+
activeTab={activeTab}
|
|
382
|
+
onTabChange={handleTabChange}
|
|
383
|
+
fields={filterFields}
|
|
384
|
+
conditions={conditions}
|
|
385
|
+
onConditionsChange={handleConditionsChange}
|
|
386
|
+
search={{
|
|
387
|
+
value: searchValue,
|
|
388
|
+
onChange: handleSearchChange,
|
|
389
|
+
placeholder: 'Search <%= title.toLowerCase() %>...',
|
|
390
|
+
}}
|
|
391
|
+
sort={{
|
|
392
|
+
options: [
|
|
393
|
+
{ id: 'name', label: 'Name' },
|
|
394
|
+
{ id: 'category', label: 'Category' },
|
|
395
|
+
{ id: 'createdAt', label: 'Created Date' },
|
|
396
|
+
],
|
|
397
|
+
value: sortBy,
|
|
398
|
+
direction: sortOrder,
|
|
399
|
+
onChange: (sortId, direction) => {
|
|
400
|
+
const params = new URLSearchParams(searchParams);
|
|
401
|
+
params.set('sortBy', sortId);
|
|
402
|
+
params.set('sortOrder', direction);
|
|
403
|
+
setSearchParams(params);
|
|
404
|
+
},
|
|
405
|
+
}}
|
|
406
|
+
inCard
|
|
407
|
+
firstInCard
|
|
408
|
+
/>
|
|
60
409
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
410
|
+
{/* Table */}
|
|
411
|
+
{loading && paginatedData.length === 0 ? (
|
|
412
|
+
<div className="el-p-md el-space-y-md">
|
|
413
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
414
|
+
<div key={i} className="flex items-center el-gap-md">
|
|
415
|
+
<Skeleton className="h-8 w-8 rounded" />
|
|
416
|
+
<Skeleton className="h-4 w-40 flex-1" />
|
|
417
|
+
<Skeleton className="h-4 w-24" />
|
|
418
|
+
<Skeleton className="h-4 w-20" />
|
|
419
|
+
<Skeleton className="h-4 w-24" />
|
|
420
|
+
</div>
|
|
421
|
+
))}
|
|
69
422
|
</div>
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
423
|
+
) : paginatedData.length === 0 && hasActiveFilters ? (
|
|
424
|
+
/* Filtered empty state - no results for current filters */
|
|
425
|
+
<div className="flex-1 flex flex-col items-center justify-center el-gap-md el-py-xl">
|
|
426
|
+
<div className="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center">
|
|
427
|
+
<SearchX className="w-8 h-8 text-muted-foreground" />
|
|
428
|
+
</div>
|
|
429
|
+
<div className="text-center el-space-y-xs">
|
|
430
|
+
<h3 className="text-lg font-medium text-foreground">No results found</h3>
|
|
431
|
+
<p className="text-sm text-muted-foreground max-w-md">
|
|
432
|
+
No <%= title.toLowerCase() %> match your current filters. Try adjusting your search or filters.
|
|
433
|
+
</p>
|
|
434
|
+
</div>
|
|
435
|
+
<Button variant="outline" onClick={clearFilters}>
|
|
436
|
+
Clear filters
|
|
437
|
+
</Button>
|
|
79
438
|
</div>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
439
|
+
) : paginatedData.length === 0 ? (
|
|
440
|
+
/* True empty state - no data at all */
|
|
441
|
+
<div className="flex-1 flex flex-col items-center justify-center el-gap-md el-py-xl">
|
|
442
|
+
<div className="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center">
|
|
443
|
+
<<%= icon %> className="w-8 h-8 text-muted-foreground" />
|
|
444
|
+
</div>
|
|
445
|
+
<div className="text-center el-space-y-xs">
|
|
446
|
+
<h3 className="text-lg font-medium text-foreground">No <%= title.toLowerCase() %> yet</h3>
|
|
447
|
+
<p className="text-sm text-muted-foreground max-w-md">
|
|
448
|
+
Get started by creating your first <%= singularTitle.toLowerCase() %>.
|
|
449
|
+
</p>
|
|
450
|
+
</div>
|
|
451
|
+
<Button onClick={() => navigate('/<%= routePath %>/new')}>
|
|
452
|
+
<Plus className="w-4 h-4 mr-2" />
|
|
453
|
+
Add <%= singularTitle %>
|
|
454
|
+
</Button>
|
|
89
455
|
</div>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
data={itemStats}
|
|
97
|
-
height={100}
|
|
98
|
-
showLegend={false}
|
|
99
|
-
centerValue={String(items.length)}
|
|
100
|
-
centerSubtext="Items"
|
|
456
|
+
) : (
|
|
457
|
+
<Table
|
|
458
|
+
data={paginatedData}
|
|
459
|
+
columns={columns}
|
|
460
|
+
onRowClick={(item) => navigate(`/<%= routePath %>/${item.id}`)}
|
|
461
|
+
inCard="last"
|
|
101
462
|
/>
|
|
102
|
-
|
|
103
|
-
</
|
|
104
|
-
</div>
|
|
463
|
+
)}
|
|
464
|
+
</div>
|
|
105
465
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
466
|
+
{/* Pagination */}
|
|
467
|
+
{totalPages > 1 && (
|
|
468
|
+
<div className="flex items-center justify-between border rounded-lg el-px-lg el-py-md bg-muted/20">
|
|
469
|
+
<p className="text-sm text-muted-foreground">
|
|
470
|
+
Showing {(page - 1) * perPage + 1} to{' '}
|
|
471
|
+
{Math.min(page * perPage, filteredData.length)} of{' '}
|
|
472
|
+
{filteredData.length} <%= title.toLowerCase() %>
|
|
473
|
+
</p>
|
|
474
|
+
<div className="flex items-center el-gap-field">
|
|
475
|
+
<Button
|
|
476
|
+
variant="outline"
|
|
477
|
+
size="sm"
|
|
478
|
+
disabled={page <= 1}
|
|
479
|
+
onClick={() => handlePageChange(page - 1)}
|
|
480
|
+
>
|
|
481
|
+
Previous
|
|
482
|
+
</Button>
|
|
483
|
+
<span className="text-sm text-muted-foreground">
|
|
484
|
+
Page {page} of {totalPages}
|
|
485
|
+
</span>
|
|
486
|
+
<Button
|
|
487
|
+
variant="outline"
|
|
488
|
+
size="sm"
|
|
489
|
+
disabled={page >= totalPages}
|
|
490
|
+
onClick={() => handlePageChange(page + 1)}
|
|
491
|
+
>
|
|
492
|
+
Next
|
|
493
|
+
</Button>
|
|
494
|
+
</div>
|
|
112
495
|
</div>
|
|
113
|
-
|
|
114
|
-
<Select
|
|
115
|
-
options={[
|
|
116
|
-
{ value: 'all', label: 'All Types' },
|
|
117
|
-
{ value: 'type-a', label: 'Type A' },
|
|
118
|
-
{ value: 'type-b', label: 'Type B' },
|
|
119
|
-
{ value: 'type-c', label: 'Type C' },
|
|
120
|
-
]}
|
|
121
|
-
placeholder="Filter by type"
|
|
122
|
-
hideMessage
|
|
123
|
-
/>
|
|
124
|
-
<Button variant="outline">
|
|
125
|
-
<Filter className="w-4 h-4 mr-2" />
|
|
126
|
-
Filters
|
|
127
|
-
</Button>
|
|
128
|
-
</div>
|
|
496
|
+
)}
|
|
129
497
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
498
|
+
{/* Collapsible Page Info - shows when items exist */}
|
|
499
|
+
{paginatedData.length > 0 && (
|
|
500
|
+
<div className="relative">
|
|
501
|
+
{isInfoCollapsed ? (
|
|
502
|
+
<button
|
|
503
|
+
onClick={toggleInfoCollapsed}
|
|
504
|
+
className={cn(
|
|
505
|
+
'w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border',
|
|
506
|
+
'bg-gradient-to-r from-primary/[0.02] to-primary/[0.04]',
|
|
507
|
+
'border-border/50',
|
|
508
|
+
'text-muted-foreground text-sm',
|
|
509
|
+
'hover:bg-primary/[0.05] hover:border-primary/30 transition-colors',
|
|
510
|
+
'cursor-pointer'
|
|
511
|
+
)}
|
|
512
|
+
>
|
|
513
|
+
<Info className="w-4 h-4" />
|
|
514
|
+
<span className="font-medium">About <%= title %></span>
|
|
515
|
+
<ChevronDown className="w-4 h-4" />
|
|
516
|
+
</button>
|
|
517
|
+
) : (
|
|
518
|
+
<div className="relative">
|
|
519
|
+
<div className={cn(
|
|
520
|
+
'flex flex-col lg:flex-row items-stretch w-full',
|
|
521
|
+
'bg-gradient-to-br from-primary/[0.02] via-transparent to-primary/[0.04]',
|
|
522
|
+
'rounded-xl border border-border/50',
|
|
523
|
+
'overflow-hidden'
|
|
524
|
+
)}>
|
|
525
|
+
{/* Left Column - Illustration and What Is It */}
|
|
526
|
+
<div className={cn(
|
|
527
|
+
'flex flex-col items-center justify-center',
|
|
528
|
+
'lg:w-[40%] lg:min-w-[280px] lg:max-w-[380px]',
|
|
529
|
+
'bg-gradient-to-br from-primary/5 to-primary/10',
|
|
530
|
+
'border-b lg:border-b-0 lg:border-r border-border/30',
|
|
531
|
+
'p-6 lg:p-8'
|
|
532
|
+
)}>
|
|
533
|
+
<div className="relative mb-6">
|
|
534
|
+
<div className="absolute -inset-4 bg-primary/5 rounded-full blur-2xl" />
|
|
535
|
+
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/10 rounded-full blur-xl" />
|
|
536
|
+
<div className="absolute bottom-0 left-0 w-12 h-12 bg-primary/10 rounded-full blur-lg" />
|
|
537
|
+
<div className="relative w-24 h-24 rounded-full bg-primary/10 flex items-center justify-center">
|
|
538
|
+
<<%= icon %> className="w-12 h-12 text-primary" />
|
|
147
539
|
</div>
|
|
148
|
-
<span className="font-medium text-foreground">{item.name}</span>
|
|
149
540
|
</div>
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
541
|
+
<div className="w-full bg-background/60 rounded-lg p-4 border border-primary/20">
|
|
542
|
+
<h4 className="font-medium text-sm text-primary mb-1">What are <%= title %>?</h4>
|
|
543
|
+
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
544
|
+
<%= description %>
|
|
545
|
+
</p>
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
|
|
549
|
+
{/* Right Column - Content */}
|
|
550
|
+
<div className="flex-1 p-6 lg:p-8 el-space-y-lg">
|
|
551
|
+
<div className="el-space-y-sm">
|
|
552
|
+
<h3 className="text-xl lg:text-2xl font-semibold text-foreground">About <%= title %></h3>
|
|
553
|
+
<p className="text-muted-foreground text-base leading-relaxed max-w-lg">
|
|
554
|
+
<%= description %>
|
|
555
|
+
</p>
|
|
556
|
+
</div>
|
|
557
|
+
|
|
558
|
+
{/* Features Grid */}
|
|
559
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
560
|
+
<div className={cn(
|
|
561
|
+
'flex items-start gap-3 p-3',
|
|
562
|
+
'bg-background/60 rounded-lg border border-border/40',
|
|
563
|
+
'transition-colors hover:border-primary/30 hover:bg-primary/[0.02]'
|
|
564
|
+
)}>
|
|
565
|
+
<div className="shrink-0 w-8 h-8 rounded-md bg-primary/10 text-primary flex items-center justify-center">
|
|
566
|
+
<Tag className="w-4 h-4" />
|
|
567
|
+
</div>
|
|
568
|
+
<div className="min-w-0">
|
|
569
|
+
<p className="font-medium text-sm text-foreground">Categories</p>
|
|
570
|
+
<p className="text-xs text-muted-foreground mt-0.5">Organize by category</p>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
<div className={cn(
|
|
574
|
+
'flex items-start gap-3 p-3',
|
|
575
|
+
'bg-background/60 rounded-lg border border-border/40',
|
|
576
|
+
'transition-colors hover:border-primary/30 hover:bg-primary/[0.02]'
|
|
577
|
+
)}>
|
|
578
|
+
<div className="shrink-0 w-8 h-8 rounded-md bg-primary/10 text-primary flex items-center justify-center">
|
|
579
|
+
<BarChart3 className="w-4 h-4" />
|
|
580
|
+
</div>
|
|
581
|
+
<div className="min-w-0">
|
|
582
|
+
<p className="font-medium text-sm text-foreground">Analytics</p>
|
|
583
|
+
<p className="text-xs text-muted-foreground mt-0.5">Track and analyze data</p>
|
|
584
|
+
</div>
|
|
585
|
+
</div>
|
|
586
|
+
<div className={cn(
|
|
587
|
+
'flex items-start gap-3 p-3',
|
|
588
|
+
'bg-background/60 rounded-lg border border-border/40',
|
|
589
|
+
'transition-colors hover:border-primary/30 hover:bg-primary/[0.02]'
|
|
590
|
+
)}>
|
|
591
|
+
<div className="shrink-0 w-8 h-8 rounded-md bg-primary/10 text-primary flex items-center justify-center">
|
|
592
|
+
<Layers className="w-4 h-4" />
|
|
593
|
+
</div>
|
|
594
|
+
<div className="min-w-0">
|
|
595
|
+
<p className="font-medium text-sm text-foreground">Organization</p>
|
|
596
|
+
<p className="text-xs text-muted-foreground mt-0.5">Keep everything organized</p>
|
|
597
|
+
</div>
|
|
598
|
+
</div>
|
|
599
|
+
<div className={cn(
|
|
600
|
+
'flex items-start gap-3 p-3',
|
|
601
|
+
'bg-background/60 rounded-lg border border-border/40',
|
|
602
|
+
'transition-colors hover:border-primary/30 hover:bg-primary/[0.02]'
|
|
603
|
+
)}>
|
|
604
|
+
<div className="shrink-0 w-8 h-8 rounded-md bg-primary/10 text-primary flex items-center justify-center">
|
|
605
|
+
<Settings className="w-4 h-4" />
|
|
606
|
+
</div>
|
|
607
|
+
<div className="min-w-0">
|
|
608
|
+
<p className="font-medium text-sm text-foreground">Status</p>
|
|
609
|
+
<p className="text-xs text-muted-foreground mt-0.5">Active, draft, or archived</p>
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
</div>
|
|
615
|
+
|
|
616
|
+
{/* Collapse toggle at bottom-center */}
|
|
617
|
+
<button
|
|
618
|
+
onClick={toggleInfoCollapsed}
|
|
619
|
+
className={cn(
|
|
620
|
+
'absolute -bottom-px left-1/2 -translate-x-1/2',
|
|
621
|
+
'flex items-center gap-1.5 px-3 py-1',
|
|
622
|
+
'bg-background border border-t-0 border-border/50 rounded-b-lg',
|
|
623
|
+
'text-xs text-muted-foreground',
|
|
624
|
+
'hover:text-foreground hover:bg-muted/50 transition-colors',
|
|
625
|
+
'cursor-pointer'
|
|
626
|
+
)}
|
|
627
|
+
>
|
|
628
|
+
<span>Hide</span>
|
|
629
|
+
<ChevronUp className="w-3.5 h-3.5" />
|
|
630
|
+
</button>
|
|
631
|
+
</div>
|
|
632
|
+
)}
|
|
633
|
+
</div>
|
|
634
|
+
)}
|
|
188
635
|
</div>
|
|
636
|
+
|
|
637
|
+
{/* Delete Confirmation Modal */}
|
|
638
|
+
<Modal
|
|
639
|
+
isOpen={deleteModalOpen}
|
|
640
|
+
onClose={() => setDeleteModalOpen(false)}
|
|
641
|
+
title="Delete <%= singularTitle %>"
|
|
642
|
+
>
|
|
643
|
+
<div className="el-space-y-md">
|
|
644
|
+
<p>
|
|
645
|
+
Are you sure you want to delete <strong>{itemToDelete?.name}</strong>?
|
|
646
|
+
This action cannot be undone.
|
|
647
|
+
</p>
|
|
648
|
+
<div className="flex justify-end el-gap-field">
|
|
649
|
+
<Button variant="outline" onClick={() => setDeleteModalOpen(false)}>
|
|
650
|
+
Cancel
|
|
651
|
+
</Button>
|
|
652
|
+
<Button variant="destructive" onClick={confirmDelete}>
|
|
653
|
+
Delete
|
|
654
|
+
</Button>
|
|
655
|
+
</div>
|
|
656
|
+
</div>
|
|
657
|
+
</Modal>
|
|
189
658
|
</div>
|
|
190
659
|
);
|
|
191
660
|
}
|
|
661
|
+
|
|
662
|
+
export default <%= pascalName %>ListPage;
|