@atlashub/smartstack-cli 4.4.0 → 4.6.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.
- package/dist/index.js +26 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/project/DependencyInjection.Application.cs.template +2 -2
- package/templates/project/Program.cs.template +91 -37
- package/templates/project/appsettings.json.template +34 -7
- package/templates/skills/apex/references/person-extension-pattern.md +7 -5
- package/templates/skills/apex/references/smartstack-frontend.md +278 -103
- package/templates/skills/apex/references/smartstack-layers.md +2 -2
- package/templates/skills/apex/steps/step-03-execute.md +18 -1
- package/templates/skills/apex/steps/step-05-deep-review.md +1 -1
- package/templates/skills/ralph-loop/references/task-transform-legacy.md +2 -2
- package/templates/skills/ui-components/SKILL.md +310 -0
- package/templates/skills/ui-components/patterns/data-table.md +136 -0
|
@@ -40,6 +40,32 @@ description: |
|
|
|
40
40
|
- For styling rules: [style-guide.md](style-guide.md)
|
|
41
41
|
- For accessibility: [accessibility.md](accessibility.md)
|
|
42
42
|
|
|
43
|
+
## Component Availability
|
|
44
|
+
|
|
45
|
+
> **Know which components to import vs generate.** Using a non-existent import causes the LLM to fall back to raw HTML.
|
|
46
|
+
|
|
47
|
+
### Shared Components (import from SmartStack package)
|
|
48
|
+
|
|
49
|
+
| Component | Import | Use for |
|
|
50
|
+
|-----------|--------|---------|
|
|
51
|
+
| `EntityCard` | `@/components/ui/EntityCard` | Entity cards with avatar, badges, actions |
|
|
52
|
+
| `DataTable` | `@/components/ui/DataTable` | Sortable, searchable, paginated tables |
|
|
53
|
+
| `PageLoader` | `@/components/ui/PageLoader` | Suspense fallback spinner |
|
|
54
|
+
| `Tooltip` | `@/components/ui/Tooltip` | Tooltips with variants (error, warning, etc.) |
|
|
55
|
+
| `DocToggleButton` | `@/components/docs/DocToggleButton` | Documentation toggle in page headers |
|
|
56
|
+
| `Loader2` | `lucide-react` | Inline loading spinner icon |
|
|
57
|
+
|
|
58
|
+
### Generate Locally (inline pattern — do NOT import from shared)
|
|
59
|
+
|
|
60
|
+
| Component | Generate where | Pattern reference |
|
|
61
|
+
|-----------|---------------|-------------------|
|
|
62
|
+
| `StatCard` | Inline in dashboard page | [patterns/dashboard-chart.md](patterns/dashboard-chart.md) |
|
|
63
|
+
| `ChartCard` | Inline in dashboard page | [patterns/dashboard-chart.md](patterns/dashboard-chart.md) |
|
|
64
|
+
| `EntityLookup` | `@/components/ui/EntityLookup` | smartstack-frontend.md section 6 |
|
|
65
|
+
| `PeriodFilter` | Inline in dashboard page | [patterns/dashboard-chart.md](patterns/dashboard-chart.md) |
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
43
69
|
## CORE RULES (always apply)
|
|
44
70
|
|
|
45
71
|
### EntityCard is Mandatory
|
|
@@ -102,6 +128,290 @@ import { EntityCard, ProviderCard, TemplateCard } from '@/components/ui/EntityCa
|
|
|
102
128
|
| `useCallback` for event handlers | Inline arrow functions in JSX |
|
|
103
129
|
| Unique `id` for list keys | Array index as key |
|
|
104
130
|
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Complete Page Templates
|
|
134
|
+
|
|
135
|
+
> **Use these templates as the starting point for every page.** They ensure CSS variables, i18n, responsive design, and correct component usage.
|
|
136
|
+
|
|
137
|
+
### List Page Template
|
|
138
|
+
|
|
139
|
+
```tsx
|
|
140
|
+
import { useState, useCallback, useEffect, memo } from 'react';
|
|
141
|
+
import { useTranslation } from 'react-i18next';
|
|
142
|
+
import { useNavigate } from 'react-router-dom';
|
|
143
|
+
import { Loader2, AlertCircle } from 'lucide-react';
|
|
144
|
+
import { DocToggleButton } from '@/components/docs/DocToggleButton';
|
|
145
|
+
import { DataTable } from '@/components/ui/DataTable';
|
|
146
|
+
|
|
147
|
+
export function EntityListPage() {
|
|
148
|
+
const { t } = useTranslation(['{module}']);
|
|
149
|
+
const navigate = useNavigate();
|
|
150
|
+
const [loading, setLoading] = useState(true);
|
|
151
|
+
const [error, setError] = useState<string | null>(null);
|
|
152
|
+
const [data, setData] = useState<Entity[]>([]);
|
|
153
|
+
|
|
154
|
+
const loadData = useCallback(async () => {
|
|
155
|
+
try {
|
|
156
|
+
setLoading(true);
|
|
157
|
+
setError(null);
|
|
158
|
+
const result = await entityApi.getAll();
|
|
159
|
+
setData(result.items);
|
|
160
|
+
} catch (err: any) {
|
|
161
|
+
setError(err.message || t('{module}:errors.loadFailed', 'Failed to load data'));
|
|
162
|
+
} finally {
|
|
163
|
+
setLoading(false);
|
|
164
|
+
}
|
|
165
|
+
}, [t]);
|
|
166
|
+
|
|
167
|
+
useEffect(() => { loadData(); }, [loadData]);
|
|
168
|
+
|
|
169
|
+
if (loading) {
|
|
170
|
+
return (
|
|
171
|
+
<div className="flex items-center justify-center min-h-[400px]">
|
|
172
|
+
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (error) {
|
|
178
|
+
return (
|
|
179
|
+
<div className="flex items-center justify-center min-h-[400px]">
|
|
180
|
+
<div className="text-center">
|
|
181
|
+
<AlertCircle className="h-8 w-8 text-[var(--error-text)] mx-auto mb-2" />
|
|
182
|
+
<p className="text-[var(--text-secondary)]">{error}</p>
|
|
183
|
+
<button onClick={loadData} className="mt-4 px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)]">
|
|
184
|
+
{t('common:actions.retry', 'Retry')}
|
|
185
|
+
</button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div className="space-y-6">
|
|
193
|
+
<div className="flex items-center justify-between">
|
|
194
|
+
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
|
195
|
+
{t('{module}:title', 'Module Title')}
|
|
196
|
+
</h1>
|
|
197
|
+
<div className="flex items-center gap-2">
|
|
198
|
+
<DocToggleButton />
|
|
199
|
+
<button onClick={() => navigate('create')} className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)]">
|
|
200
|
+
{t('{module}:actions.create', 'Create')}
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{data.length === 0 ? (
|
|
206
|
+
<div className="text-center py-12 text-[var(--text-secondary)]">
|
|
207
|
+
{t('{module}:empty', 'No items found.')}
|
|
208
|
+
</div>
|
|
209
|
+
) : (
|
|
210
|
+
<DataTable
|
|
211
|
+
data={data}
|
|
212
|
+
columns={[
|
|
213
|
+
{ key: 'name', label: t('{module}:columns.name', 'Name'), sortable: true },
|
|
214
|
+
{ key: 'code', label: t('{module}:columns.code', 'Code'), sortable: true },
|
|
215
|
+
{ key: 'status', label: t('{module}:columns.status', 'Status'),
|
|
216
|
+
render: (item) => (
|
|
217
|
+
<span className={`px-2 py-0.5 rounded text-xs ${
|
|
218
|
+
item.isActive
|
|
219
|
+
? 'bg-[var(--success-bg)] text-[var(--success-text)]'
|
|
220
|
+
: 'bg-[var(--error-bg)] text-[var(--error-text)]'
|
|
221
|
+
}`}>
|
|
222
|
+
{item.isActive ? t('common:status.active', 'Active') : t('common:status.inactive', 'Inactive')}
|
|
223
|
+
</span>
|
|
224
|
+
)
|
|
225
|
+
},
|
|
226
|
+
]}
|
|
227
|
+
searchable
|
|
228
|
+
pagination={{ pageSize: 10 }}
|
|
229
|
+
onRowClick={(item) => navigate(`${item.id}`)}
|
|
230
|
+
/>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Form Page Template (Create)
|
|
238
|
+
|
|
239
|
+
```tsx
|
|
240
|
+
import { useState } from 'react';
|
|
241
|
+
import { useTranslation } from 'react-i18next';
|
|
242
|
+
import { useNavigate } from 'react-router-dom';
|
|
243
|
+
import { ArrowLeft } from 'lucide-react';
|
|
244
|
+
// For FK Guid fields: import { EntityLookup } from '@/components/ui/EntityLookup';
|
|
245
|
+
|
|
246
|
+
export function EntityCreatePage() {
|
|
247
|
+
const { t } = useTranslation(['{module}']);
|
|
248
|
+
const navigate = useNavigate();
|
|
249
|
+
const [submitting, setSubmitting] = useState(false);
|
|
250
|
+
const [error, setError] = useState<string | null>(null);
|
|
251
|
+
const [formData, setFormData] = useState<CreateEntityDto>({ name: '' });
|
|
252
|
+
|
|
253
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
try {
|
|
256
|
+
setSubmitting(true);
|
|
257
|
+
setError(null);
|
|
258
|
+
await entityApi.create(formData);
|
|
259
|
+
navigate(-1);
|
|
260
|
+
} catch (err: any) {
|
|
261
|
+
setError(err.message || t('{module}:errors.createFailed', 'Creation failed'));
|
|
262
|
+
} finally {
|
|
263
|
+
setSubmitting(false);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<div className="space-y-6">
|
|
269
|
+
<button onClick={() => navigate(-1)} className="flex items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
|
|
270
|
+
<ArrowLeft className="w-4 h-4" />
|
|
271
|
+
{t('common:actions.back', 'Back')}
|
|
272
|
+
</button>
|
|
273
|
+
|
|
274
|
+
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
|
275
|
+
{t('{module}:actions.create', 'Create {Entity}')}
|
|
276
|
+
</h1>
|
|
277
|
+
|
|
278
|
+
{error && (
|
|
279
|
+
<div className="p-4 bg-[var(--error-bg)] border border-[var(--error-border)] rounded-[var(--radius-card)]">
|
|
280
|
+
<span className="text-[var(--error-text)]">{error}</span>
|
|
281
|
+
</div>
|
|
282
|
+
)}
|
|
283
|
+
|
|
284
|
+
<form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
|
|
285
|
+
{/* Text field */}
|
|
286
|
+
<div>
|
|
287
|
+
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
288
|
+
{t('{module}:form.name', 'Name')}
|
|
289
|
+
</label>
|
|
290
|
+
<input
|
|
291
|
+
type="text"
|
|
292
|
+
value={formData.name}
|
|
293
|
+
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
|
294
|
+
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--bg-card)] text-[var(--text-primary)] rounded-[var(--radius-input)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-500)]"
|
|
295
|
+
required
|
|
296
|
+
/>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{/* FK Guid field — ALWAYS EntityLookup, NEVER <select> */}
|
|
300
|
+
{/* <EntityLookup
|
|
301
|
+
apiEndpoint="/api/{app}/{module}/departments"
|
|
302
|
+
value={formData.departmentId}
|
|
303
|
+
onChange={(id) => setFormData(prev => ({ ...prev, departmentId: id }))}
|
|
304
|
+
mapOption={(dept) => ({ label: dept.name, value: dept.id })}
|
|
305
|
+
placeholder={t('{module}:form.selectDepartment', 'Select...')}
|
|
306
|
+
/> */}
|
|
307
|
+
|
|
308
|
+
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
|
|
309
|
+
<button type="button" onClick={() => navigate(-1)} className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]">
|
|
310
|
+
{t('common:actions.cancel', 'Cancel')}
|
|
311
|
+
</button>
|
|
312
|
+
<button type="submit" disabled={submitting} className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)] hover:bg-[var(--color-accent-600)] disabled:opacity-50">
|
|
313
|
+
{submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
|
|
314
|
+
</button>
|
|
315
|
+
</div>
|
|
316
|
+
</form>
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Dashboard Page Template
|
|
323
|
+
|
|
324
|
+
```tsx
|
|
325
|
+
import { useState, useCallback, useEffect, useMemo } from 'react';
|
|
326
|
+
import { useTranslation } from 'react-i18next';
|
|
327
|
+
import { Loader2, AlertCircle, TrendingUp, TrendingDown, Users, Package } from 'lucide-react';
|
|
328
|
+
import { DocToggleButton } from '@/components/docs/DocToggleButton';
|
|
329
|
+
|
|
330
|
+
// StatCard — generated locally per dashboard (NOT a shared import)
|
|
331
|
+
interface StatCardProps {
|
|
332
|
+
title: string;
|
|
333
|
+
value: string | number;
|
|
334
|
+
icon: React.ReactNode;
|
|
335
|
+
trend?: { value: number; label: string };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const StatCard = memo(function StatCard({ title, value, icon, trend }: StatCardProps) {
|
|
339
|
+
return (
|
|
340
|
+
<div className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6">
|
|
341
|
+
<div className="flex items-center justify-between">
|
|
342
|
+
<div>
|
|
343
|
+
<p className="text-sm text-[var(--text-secondary)]">{title}</p>
|
|
344
|
+
<p className="text-2xl font-bold text-[var(--text-primary)] mt-1">{value}</p>
|
|
345
|
+
{trend && (
|
|
346
|
+
<div className={`flex items-center gap-1 mt-2 text-sm ${
|
|
347
|
+
trend.value >= 0 ? 'text-[var(--success-text)]' : 'text-[var(--error-text)]'
|
|
348
|
+
}`}>
|
|
349
|
+
{trend.value >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
|
350
|
+
<span>{Math.abs(trend.value)}% {trend.label}</span>
|
|
351
|
+
</div>
|
|
352
|
+
)}
|
|
353
|
+
</div>
|
|
354
|
+
<div className="p-3 bg-[var(--color-accent-50)] rounded-[var(--radius-card)]">
|
|
355
|
+
{icon}
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
export function DashboardPage() {
|
|
363
|
+
const { t } = useTranslation(['{module}']);
|
|
364
|
+
const [loading, setLoading] = useState(true);
|
|
365
|
+
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
366
|
+
|
|
367
|
+
const loadStats = useCallback(async () => {
|
|
368
|
+
try {
|
|
369
|
+
setLoading(true);
|
|
370
|
+
const result = await dashboardApi.getStats();
|
|
371
|
+
setStats(result);
|
|
372
|
+
} catch {
|
|
373
|
+
// Handle error
|
|
374
|
+
} finally {
|
|
375
|
+
setLoading(false);
|
|
376
|
+
}
|
|
377
|
+
}, []);
|
|
378
|
+
|
|
379
|
+
useEffect(() => { loadStats(); }, [loadStats]);
|
|
380
|
+
|
|
381
|
+
if (loading) {
|
|
382
|
+
return (
|
|
383
|
+
<div className="flex items-center justify-center min-h-[400px]">
|
|
384
|
+
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return (
|
|
390
|
+
<div className="space-y-6">
|
|
391
|
+
<div className="flex items-center justify-between">
|
|
392
|
+
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
|
393
|
+
{t('{module}:dashboard.title', 'Dashboard')}
|
|
394
|
+
</h1>
|
|
395
|
+
<DocToggleButton />
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
{/* KPI Grid — responsive 1→2→4 */}
|
|
399
|
+
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
|
400
|
+
<StatCard
|
|
401
|
+
title={t('{module}:dashboard.totalUsers', 'Total Users')}
|
|
402
|
+
value={stats?.totalUsers ?? 0}
|
|
403
|
+
icon={<Users className="w-6 h-6 text-[var(--color-accent-500)]" />}
|
|
404
|
+
trend={{ value: 12, label: t('{module}:dashboard.vsLastMonth', 'vs last month') }}
|
|
405
|
+
/>
|
|
406
|
+
{/* Add more StatCards... */}
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
---
|
|
414
|
+
|
|
105
415
|
### Error States with Retry
|
|
106
416
|
|
|
107
417
|
**ALWAYS provide user-friendly error states with retry capability.**
|
|
@@ -17,6 +17,142 @@ import { DataTable } from '@/components/ui/DataTable';
|
|
|
17
17
|
/>
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
## Full List Page with DataTable
|
|
21
|
+
|
|
22
|
+
> **Complete page showing DataTable integration with loading, error, empty states, CSS variables, and i18n.**
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
26
|
+
import { useTranslation } from 'react-i18next';
|
|
27
|
+
import { useNavigate } from 'react-router-dom';
|
|
28
|
+
import { Loader2, AlertCircle } from 'lucide-react';
|
|
29
|
+
import { DocToggleButton } from '@/components/docs/DocToggleButton';
|
|
30
|
+
import { DataTable } from '@/components/ui/DataTable';
|
|
31
|
+
|
|
32
|
+
interface User {
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
email: string;
|
|
36
|
+
role: string;
|
|
37
|
+
isActive: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function UsersPage() {
|
|
41
|
+
const { t } = useTranslation(['users']);
|
|
42
|
+
const navigate = useNavigate();
|
|
43
|
+
const [loading, setLoading] = useState(true);
|
|
44
|
+
const [error, setError] = useState<string | null>(null);
|
|
45
|
+
const [users, setUsers] = useState<User[]>([]);
|
|
46
|
+
|
|
47
|
+
const loadData = useCallback(async () => {
|
|
48
|
+
try {
|
|
49
|
+
setLoading(true);
|
|
50
|
+
setError(null);
|
|
51
|
+
const result = await usersApi.getAll();
|
|
52
|
+
setUsers(result.items);
|
|
53
|
+
} catch (err: any) {
|
|
54
|
+
setError(err.message || t('users:errors.loadFailed', 'Failed to load users'));
|
|
55
|
+
} finally {
|
|
56
|
+
setLoading(false);
|
|
57
|
+
}
|
|
58
|
+
}, [t]);
|
|
59
|
+
|
|
60
|
+
useEffect(() => { loadData(); }, [loadData]);
|
|
61
|
+
|
|
62
|
+
// Loading state
|
|
63
|
+
if (loading) {
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex items-center justify-center min-h-[400px]">
|
|
66
|
+
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Error state with retry
|
|
72
|
+
if (error) {
|
|
73
|
+
return (
|
|
74
|
+
<div className="flex items-center justify-center min-h-[400px]">
|
|
75
|
+
<div className="text-center">
|
|
76
|
+
<AlertCircle className="h-8 w-8 text-[var(--error-text)] mx-auto mb-2" />
|
|
77
|
+
<p className="text-[var(--text-secondary)]">{error}</p>
|
|
78
|
+
<button
|
|
79
|
+
onClick={loadData}
|
|
80
|
+
className="mt-4 px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)]"
|
|
81
|
+
>
|
|
82
|
+
{t('common:actions.retry', 'Retry')}
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="space-y-6">
|
|
91
|
+
{/* Header */}
|
|
92
|
+
<div className="flex items-center justify-between">
|
|
93
|
+
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
|
94
|
+
{t('users:title', 'Users')}
|
|
95
|
+
</h1>
|
|
96
|
+
<div className="flex items-center gap-2">
|
|
97
|
+
<DocToggleButton />
|
|
98
|
+
<button
|
|
99
|
+
onClick={() => navigate('create')}
|
|
100
|
+
className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)]"
|
|
101
|
+
>
|
|
102
|
+
{t('users:actions.create', 'Create User')}
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{/* Empty state */}
|
|
108
|
+
{users.length === 0 ? (
|
|
109
|
+
<div className="text-center py-12 text-[var(--text-secondary)]">
|
|
110
|
+
{t('users:empty', 'No users found.')}
|
|
111
|
+
</div>
|
|
112
|
+
) : (
|
|
113
|
+
/* DataTable with sorting, search, pagination, row click */
|
|
114
|
+
<DataTable
|
|
115
|
+
data={users}
|
|
116
|
+
columns={[
|
|
117
|
+
{ key: 'name', label: t('users:columns.name', 'Name'), sortable: true },
|
|
118
|
+
{ key: 'email', label: t('users:columns.email', 'Email'), sortable: true },
|
|
119
|
+
{ key: 'role', label: t('users:columns.role', 'Role'), sortable: true },
|
|
120
|
+
{
|
|
121
|
+
key: 'isActive',
|
|
122
|
+
label: t('users:columns.status', 'Status'),
|
|
123
|
+
render: (user) => (
|
|
124
|
+
<span className={`px-2 py-0.5 rounded text-xs ${
|
|
125
|
+
user.isActive
|
|
126
|
+
? 'bg-[var(--success-bg)] text-[var(--success-text)]'
|
|
127
|
+
: 'bg-[var(--error-bg)] text-[var(--error-text)]'
|
|
128
|
+
}`}>
|
|
129
|
+
{user.isActive
|
|
130
|
+
? t('common:status.active', 'Active')
|
|
131
|
+
: t('common:status.inactive', 'Inactive')}
|
|
132
|
+
</span>
|
|
133
|
+
),
|
|
134
|
+
},
|
|
135
|
+
]}
|
|
136
|
+
searchable
|
|
137
|
+
pagination={{ pageSize: 10 }}
|
|
138
|
+
onRowClick={(user) => navigate(`${user.id}`)}
|
|
139
|
+
/>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Key points:**
|
|
147
|
+
- **NEVER** use raw `<table>` — always use `DataTable`
|
|
148
|
+
- **ALL** text uses `t('namespace:key', 'Fallback')` for i18n
|
|
149
|
+
- **ALL** colors use CSS variables (`var(--...)`) — zero hardcoded Tailwind colors
|
|
150
|
+
- **Loading/Error/Empty** states are always handled
|
|
151
|
+
- **Row click** navigates to detail page via relative path
|
|
152
|
+
- **Status badges** use semantic CSS variables (`--success-bg`, `--error-text`)
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
20
156
|
## COMPONENT: Tooltip
|
|
21
157
|
|
|
22
158
|
```tsx
|