@atlashub/smartstack-cli 4.5.0 → 4.7.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.
@@ -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