@atlashub/smartstack-cli 3.23.0 → 3.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/index.js +5 -0
  2. package/dist/index.js.map +1 -1
  3. package/dist/mcp-entry.mjs +96 -24
  4. package/dist/mcp-entry.mjs.map +1 -1
  5. package/package.json +1 -1
  6. package/templates/mcp-scaffolding/component.tsx.hbs +21 -1
  7. package/templates/skills/apex/references/smartstack-api.md +174 -5
  8. package/templates/skills/apex/references/smartstack-frontend.md +1101 -0
  9. package/templates/skills/apex/references/smartstack-layers.md +81 -5
  10. package/templates/skills/apex/steps/step-01-analyze.md +27 -3
  11. package/templates/skills/apex/steps/step-02-plan.md +5 -1
  12. package/templates/skills/apex/steps/step-03-execute.md +47 -5
  13. package/templates/skills/apex/steps/step-04-validate.md +300 -0
  14. package/templates/skills/apex/steps/step-05-examine.md +7 -0
  15. package/templates/skills/apex/steps/step-07-tests.md +19 -0
  16. package/templates/skills/business-analyse/_shared.md +6 -6
  17. package/templates/skills/business-analyse/patterns/suggestion-catalog.md +1 -1
  18. package/templates/skills/business-analyse/questionnaire/07-ui.md +3 -3
  19. package/templates/skills/business-analyse/references/cadrage-pre-analysis.md +1 -1
  20. package/templates/skills/business-analyse/references/entity-architecture-decision.md +3 -3
  21. package/templates/skills/business-analyse/references/handoff-file-templates.md +13 -5
  22. package/templates/skills/business-analyse/references/spec-auto-inference.md +14 -14
  23. package/templates/skills/business-analyse/steps/step-01-cadrage.md +2 -2
  24. package/templates/skills/business-analyse/steps/step-02-decomposition.md +1 -1
  25. package/templates/skills/business-analyse/steps/step-03a1-setup.md +2 -2
  26. package/templates/skills/business-analyse/steps/step-03b-ui.md +2 -1
  27. package/templates/skills/business-analyse/steps/step-05a-handoff.md +15 -4
  28. package/templates/skills/business-analyse/templates/tpl-frd.md +2 -2
  29. package/templates/skills/business-analyse/templates-frd.md +2 -2
  30. package/templates/skills/efcore/steps/migration/step-02-create.md +14 -1
  31. package/templates/skills/ralph-loop/references/category-rules.md +71 -9
  32. package/templates/skills/ralph-loop/references/compact-loop.md +3 -3
  33. package/templates/skills/ralph-loop/references/core-seed-data.md +10 -0
  34. package/templates/skills/ralph-loop/steps/step-02-execute.md +190 -1
  35. package/templates/skills/validate-feature/steps/step-01-compile.md +4 -1
  36. package/templates/skills/validate-feature/steps/step-05-db-validation.md +86 -1
@@ -0,0 +1,1101 @@
1
+ # SmartStack Frontend Patterns — Mandatory Reference
2
+
3
+ > **Loaded by:** step-03 (execution) and step-04 (validation)
4
+ > **Purpose:** Defines mandatory frontend patterns extracted from SmartStack.app.
5
+ > **Enforcement:** POST-CHECKs in step-04 verify compliance.
6
+
7
+ ---
8
+
9
+ ## 1. Lazy Loading (React.lazy + Suspense)
10
+
11
+ > **ALL page components MUST be lazy-loaded.** Only critical entry pages (HomePage, LoginPage) may use static imports.
12
+
13
+ ### Import Pattern
14
+
15
+ ```tsx
16
+ // Named exports — use .then() to wrap
17
+ const EmployeesPage = lazy(() =>
18
+ import('@/pages/Business/HumanResources/Employees/EmployeesPage')
19
+ .then(m => ({ default: m.EmployeesPage }))
20
+ );
21
+
22
+ // Default exports — direct lazy
23
+ const DashboardPage = lazy(() => import('@/pages/Platform/Admin/DashboardPage'));
24
+ ```
25
+
26
+ ### Suspense Wrapper
27
+
28
+ ```tsx
29
+ import { Suspense } from 'react';
30
+ import { PageLoader } from '@/components/ui/PageLoader';
31
+
32
+ // Route element wrapping
33
+ element: (
34
+ <Suspense fallback={<PageLoader />}>
35
+ <PermissionGuard permissions={ROUTES['business.hr.employees'].permissions}>
36
+ <EmployeesPage />
37
+ </PermissionGuard>
38
+ </Suspense>
39
+ )
40
+ ```
41
+
42
+ ### Rules
43
+
44
+ - **NEVER** static-import page components in route files
45
+ - **ALWAYS** use `<Suspense fallback={<PageLoader />}>` around lazy components
46
+ - **ALWAYS** use the `.then(m => ({ default: m.ComponentName }))` pattern for named exports
47
+ - Layout components (AdminLayout, BusinessLayout, UserLayout) are ALSO lazy-loaded
48
+
49
+ **FORBIDDEN:**
50
+ ```tsx
51
+ // WRONG: static import in route file
52
+ import { EmployeesPage } from '@/pages/Business/HumanResources/Employees/EmployeesPage';
53
+
54
+ // WRONG: no Suspense wrapper
55
+ element: <EmployeesPage />
56
+
57
+ // WRONG: no fallback
58
+ <Suspense><EmployeesPage /></Suspense>
59
+ ```
60
+
61
+ ### Client App.tsx — Lazy Imports Mandatory
62
+
63
+ > **CRITICAL:** In the client `App.tsx` (where `contextRoutes` are defined), ALL page imports MUST use `React.lazy()`.
64
+
65
+ **CORRECT — Lazy imports in client App.tsx:**
66
+ ```tsx
67
+ const ClientsListPage = lazy(() =>
68
+ import('@/pages/Business/HumanResources/Clients/ClientsListPage')
69
+ .then(m => ({ default: m.ClientsListPage }))
70
+ );
71
+ ```
72
+
73
+ **FORBIDDEN — Static imports in client App.tsx:**
74
+ ```tsx
75
+ // WRONG: Static import kills code splitting
76
+ import { ClientsListPage } from '@/pages/Business/HumanResources/Clients/ClientsListPage';
77
+ ```
78
+
79
+ > **Note:** The `smartstackRoutes.tsx` from the npm package may use static imports internally — this is acceptable for the package. But client `App.tsx` code MUST always use lazy imports for business pages.
80
+
81
+ ---
82
+
83
+ ## 2. I18n / Translations (react-i18next)
84
+
85
+ > **ALL user-facing text MUST use translations.** 4 languages required: fr, en, it, de.
86
+
87
+ ### File Structure
88
+
89
+ ```
90
+ src/i18n/
91
+ ├── config.ts # i18n initialization
92
+ ├── locales/
93
+ │ ├── fr/
94
+ │ │ ├── common.json # Shared keys (actions, errors, validation)
95
+ │ │ ├── navigation.json # Menu labels
96
+ │ │ └── {module}.json # Module-specific keys
97
+ │ ├── en/
98
+ │ │ └── {module}.json
99
+ │ ├── it/
100
+ │ │ └── {module}.json
101
+ │ └── de/
102
+ │ └── {module}.json
103
+ ```
104
+
105
+ ### Module JSON Template
106
+
107
+ Each new module MUST generate a translation file with this structure:
108
+
109
+ ```json
110
+ {
111
+ "title": "Module display name",
112
+ "description": "Module description",
113
+ "actions": {
114
+ "create": "Create {entity}",
115
+ "edit": "Edit {entity}",
116
+ "delete": "Delete {entity}",
117
+ "save": "Save",
118
+ "cancel": "Cancel",
119
+ "search": "Search...",
120
+ "export": "Export",
121
+ "refresh": "Refresh"
122
+ },
123
+ "labels": {
124
+ "name": "Name",
125
+ "code": "Code",
126
+ "description": "Description",
127
+ "status": "Status",
128
+ "createdAt": "Created at",
129
+ "updatedAt": "Updated at",
130
+ "createdBy": "Created by",
131
+ "isActive": "Active"
132
+ },
133
+ "columns": {
134
+ "name": "Name",
135
+ "code": "Code",
136
+ "status": "Status",
137
+ "actions": "Actions"
138
+ },
139
+ "form": {
140
+ "name": "Name",
141
+ "namePlaceholder": "Enter name...",
142
+ "code": "Code",
143
+ "codePlaceholder": "Enter code...",
144
+ "description": "Description",
145
+ "descriptionPlaceholder": "Enter description..."
146
+ },
147
+ "errors": {
148
+ "loadFailed": "Failed to load data",
149
+ "saveFailed": "Failed to save",
150
+ "deleteFailed": "Failed to delete",
151
+ "notFound": "Not found",
152
+ "permissionDenied": "Permission denied"
153
+ },
154
+ "validation": {
155
+ "nameRequired": "Name is required",
156
+ "codeRequired": "Code is required",
157
+ "nameMaxLength": "Name must be less than {{max}} characters"
158
+ },
159
+ "messages": {
160
+ "created": "{entity} created successfully",
161
+ "updated": "{entity} updated successfully",
162
+ "deleted": "{entity} deleted successfully",
163
+ "confirmDelete": "Are you sure you want to delete this {entity}?"
164
+ },
165
+ "empty": {
166
+ "title": "No {entity} found",
167
+ "description": "Create your first {entity} to get started"
168
+ }
169
+ }
170
+ ```
171
+
172
+ ### Usage in Components
173
+
174
+ ```tsx
175
+ // Hook — specify namespace(s)
176
+ const { t } = useTranslation(['employees']);
177
+
178
+ // Simple key with MANDATORY fallback
179
+ t('employees:title', 'Employees')
180
+
181
+ // Key with interpolation
182
+ t('employees:messages.created', '{{entity}} created successfully', { entity: 'Employee' })
183
+
184
+ // Namespace prefix syntax
185
+ t('employees:actions.create', 'Create employee')
186
+ t('common:actions.save', 'Save')
187
+ t('common:errors.network', 'Network error')
188
+ ```
189
+
190
+ ### Rules
191
+
192
+ - **ALWAYS** provide a fallback value as 2nd argument to `t()`
193
+ - **ALWAYS** use namespace prefix: `t('namespace:key')`
194
+ - **ALWAYS** generate 4 language files (fr, en, it, de) with identical key structures
195
+ - **NEVER** hardcode user-facing strings in JSX
196
+ - **NEVER** use `t('key')` without namespace prefix
197
+
198
+ **FORBIDDEN:**
199
+ ```tsx
200
+ // WRONG: no fallback
201
+ t('employees:title')
202
+
203
+ // WRONG: no namespace
204
+ t('title')
205
+
206
+ // WRONG: hardcoded text
207
+ <h1>Employees</h1>
208
+
209
+ // WRONG: only 2 languages generated
210
+ // Must have fr, en, it, de
211
+ ```
212
+
213
+ ---
214
+
215
+ ## 3. Page Structure Pattern
216
+
217
+ > **ALL pages MUST follow this structure.** Extracted from SmartStack.app reference implementation.
218
+
219
+ ### Standard List Page Template
220
+
221
+ ```tsx
222
+ import { useState, useCallback, useEffect } from 'react';
223
+ import { useTranslation } from 'react-i18next';
224
+ import { useNavigate, useParams } from 'react-router-dom';
225
+ import { Loader2 } from 'lucide-react';
226
+
227
+ // API hook (generated by scaffold_api_client)
228
+ import { useEntityList } from '@/hooks/useEntity';
229
+
230
+ export function EntityListPage() {
231
+ // 1. HOOKS — always at the top
232
+ const { t } = useTranslation(['{module}']);
233
+ const navigate = useNavigate();
234
+
235
+ // 2. STATE
236
+ const [loading, setLoading] = useState(true);
237
+ const [error, setError] = useState<string | null>(null);
238
+ const [data, setData] = useState<Entity[]>([]);
239
+
240
+ // 3. DATA LOADING (useCallback + useEffect)
241
+ const loadData = useCallback(async () => {
242
+ try {
243
+ setLoading(true);
244
+ setError(null);
245
+ const result = await entityApi.getAll();
246
+ setData(result.items);
247
+ } catch (err: any) {
248
+ setError(err.message || t('{module}:errors.loadFailed', 'Failed to load data'));
249
+ } finally {
250
+ setLoading(false);
251
+ }
252
+ }, [t]);
253
+
254
+ useEffect(() => {
255
+ loadData();
256
+ }, [loadData]);
257
+
258
+ // 4. LOADING STATE
259
+ if (loading) {
260
+ return (
261
+ <div className="flex items-center justify-center min-h-[400px]">
262
+ <Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
263
+ </div>
264
+ );
265
+ }
266
+
267
+ // 5. ERROR STATE
268
+ if (error) {
269
+ return (
270
+ <div className="flex items-center justify-center min-h-[400px]">
271
+ <div className="text-center">
272
+ <p className="text-[var(--text-secondary)]">{error}</p>
273
+ <button
274
+ onClick={loadData}
275
+ className="mt-4 px-4 py-2 bg-[var(--color-accent-500)] text-white rounded"
276
+ >
277
+ {t('common:actions.retry', 'Retry')}
278
+ </button>
279
+ </div>
280
+ </div>
281
+ );
282
+ }
283
+
284
+ // 6. CONTENT — create button navigates to /create route
285
+ return (
286
+ <div className="space-y-6">
287
+ {/* Header */}
288
+ <div className="flex items-center justify-between">
289
+ <h1 className="text-2xl font-bold text-[var(--text-primary)]">
290
+ {t('{module}:title', 'Module Title')}
291
+ </h1>
292
+ <button
293
+ onClick={() => navigate('create')}
294
+ className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded"
295
+ >
296
+ {t('{module}:actions.create', 'Create')}
297
+ </button>
298
+ </div>
299
+
300
+ {/* Content: SmartTable with row click → detail, edit action → /:id/edit */}
301
+ </div>
302
+ );
303
+ }
304
+ ```
305
+
306
+ ### Detail Page Pattern
307
+
308
+ ```tsx
309
+ export function EntityDetailPage() {
310
+ const { entityId } = useParams<{ entityId: string }>();
311
+ const { t } = useTranslation(['{module}']);
312
+ const navigate = useNavigate();
313
+
314
+ const [entity, setEntity] = useState<Entity | null>(null);
315
+ const [loading, setLoading] = useState(true);
316
+ const [activeTab, setActiveTab] = useState('info');
317
+
318
+ // Lazy tab loading — load data only when tab is first visited
319
+ const visitedTabsRef = useRef<Set<string>>(new Set(['info']));
320
+
321
+ useEffect(() => {
322
+ if (!visitedTabsRef.current.has(activeTab)) {
323
+ visitedTabsRef.current.add(activeTab);
324
+ // Load tab-specific data here
325
+ }
326
+ }, [activeTab]);
327
+
328
+ // Edit button navigates to /:id/edit route (NEVER opens a modal)
329
+ const handleEdit = () => navigate(`/${basePath}/${entityId}/edit`);
330
+
331
+ // ... loading/error/content pattern
332
+ }
333
+ ```
334
+
335
+ ---
336
+
337
+ ## 3b. Form Pages Pattern (Create / Edit)
338
+
339
+ > **CRITICAL: ALL forms MUST be full pages with their own URL route.**
340
+ > **NEVER use modals, dialogs, drawers, or popups for create/edit forms.**
341
+
342
+ ### Route Convention
343
+
344
+ | Action | Route pattern | Page component | File location |
345
+ |--------|--------------|----------------|---------------|
346
+ | Create | `/{module}/create` | `EntityCreatePage` | `src/pages/{Context}/{App}/{Module}/EntityCreatePage.tsx` |
347
+ | Edit | `/{module}/:id/edit` | `EntityEditPage` | `src/pages/{Context}/{App}/{Module}/EntityEditPage.tsx` |
348
+
349
+ ### Create Page Template
350
+
351
+ ```tsx
352
+ import { useState } from 'react';
353
+ import { useTranslation } from 'react-i18next';
354
+ import { useNavigate } from 'react-router-dom';
355
+
356
+ export function EntityCreatePage() {
357
+ const { t } = useTranslation(['{module}']);
358
+ const navigate = useNavigate();
359
+ const [submitting, setSubmitting] = useState(false);
360
+
361
+ const handleSubmit = async (data: CreateEntityDto) => {
362
+ try {
363
+ setSubmitting(true);
364
+ await entityApi.create(data);
365
+ navigate(-1); // Back to list
366
+ } catch (err: any) {
367
+ // Handle validation errors
368
+ } finally {
369
+ setSubmitting(false);
370
+ }
371
+ };
372
+
373
+ return (
374
+ <div className="space-y-6">
375
+ {/* Back button */}
376
+ <button
377
+ onClick={() => navigate(-1)}
378
+ className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
379
+ >
380
+ {t('common:actions.back', 'Back')}
381
+ </button>
382
+
383
+ {/* Page title */}
384
+ <h1 className="text-2xl font-bold text-[var(--text-primary)]">
385
+ {t('{module}:actions.create', 'Create {Entity}')}
386
+ </h1>
387
+
388
+ {/* SmartForm — NEVER in a modal */}
389
+ <SmartForm
390
+ fields={formFields}
391
+ onSubmit={handleSubmit}
392
+ onCancel={() => navigate(-1)}
393
+ submitting={submitting}
394
+ />
395
+ </div>
396
+ );
397
+ }
398
+ ```
399
+
400
+ ### Edit Page Template
401
+
402
+ ```tsx
403
+ import { useState, useEffect, useCallback } from 'react';
404
+ import { useTranslation } from 'react-i18next';
405
+ import { useNavigate, useParams } from 'react-router-dom';
406
+ import { Loader2 } from 'lucide-react';
407
+
408
+ export function EntityEditPage() {
409
+ const { entityId } = useParams<{ entityId: string }>();
410
+ const { t } = useTranslation(['{module}']);
411
+ const navigate = useNavigate();
412
+ const [entity, setEntity] = useState<Entity | null>(null);
413
+ const [loading, setLoading] = useState(true);
414
+ const [submitting, setSubmitting] = useState(false);
415
+
416
+ const loadEntity = useCallback(async () => {
417
+ try {
418
+ setLoading(true);
419
+ const result = await entityApi.getById(entityId!);
420
+ setEntity(result);
421
+ } catch {
422
+ navigate(-1);
423
+ } finally {
424
+ setLoading(false);
425
+ }
426
+ }, [entityId, navigate]);
427
+
428
+ useEffect(() => { loadEntity(); }, [loadEntity]);
429
+
430
+ if (loading) {
431
+ return (
432
+ <div className="flex items-center justify-center min-h-[400px]">
433
+ <Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
434
+ </div>
435
+ );
436
+ }
437
+
438
+ const handleSubmit = async (data: UpdateEntityDto) => {
439
+ try {
440
+ setSubmitting(true);
441
+ await entityApi.update(entityId!, data);
442
+ navigate(-1); // Back to detail or list
443
+ } catch (err: any) {
444
+ // Handle validation errors
445
+ } finally {
446
+ setSubmitting(false);
447
+ }
448
+ };
449
+
450
+ return (
451
+ <div className="space-y-6">
452
+ {/* Back button */}
453
+ <button
454
+ onClick={() => navigate(-1)}
455
+ className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
456
+ >
457
+ {t('common:actions.back', 'Back')}
458
+ </button>
459
+
460
+ {/* Page title */}
461
+ <h1 className="text-2xl font-bold text-[var(--text-primary)]">
462
+ {t('{module}:actions.edit', 'Edit {Entity}')}
463
+ </h1>
464
+
465
+ {/* SmartForm pre-filled — NEVER in a modal */}
466
+ <SmartForm
467
+ fields={formFields}
468
+ initialValues={entity}
469
+ onSubmit={handleSubmit}
470
+ onCancel={() => navigate(-1)}
471
+ submitting={submitting}
472
+ />
473
+ </div>
474
+ );
475
+ }
476
+ ```
477
+
478
+ ### Lazy Loading for Form Pages
479
+
480
+ ```tsx
481
+ // In route files — form pages are also lazy-loaded
482
+ const EntityCreatePage = lazy(() =>
483
+ import('@/pages/Business/HumanResources/Employees/EntityCreatePage')
484
+ .then(m => ({ default: m.EntityCreatePage }))
485
+ );
486
+ const EntityEditPage = lazy(() =>
487
+ import('@/pages/Business/HumanResources/Employees/EntityEditPage')
488
+ .then(m => ({ default: m.EntityEditPage }))
489
+ );
490
+
491
+ // Route registration — form pages have their own routes
492
+ {
493
+ path: 'employees',
494
+ children: [
495
+ { index: true, element: <Suspense fallback={<PageLoader />}><EmployeesPage /></Suspense> },
496
+ { path: 'create', element: <Suspense fallback={<PageLoader />}><EntityCreatePage /></Suspense> },
497
+ { path: ':id', element: <Suspense fallback={<PageLoader />}><EntityDetailPage /></Suspense> },
498
+ { path: ':id/edit', element: <Suspense fallback={<PageLoader />}><EntityEditPage /></Suspense> },
499
+ ]
500
+ }
501
+ ```
502
+
503
+ ### Rules
504
+
505
+ - **NEVER** use `<Modal>`, `<Dialog>`, `<Drawer>`, or `<Popup>` for create/edit forms
506
+ - **NEVER** use `useState(isOpen)` to toggle form visibility — forms are pages, not overlays
507
+ - **ALWAYS** create a dedicated `EntityCreatePage.tsx` and `EntityEditPage.tsx` page component
508
+ - **ALWAYS** register create/edit routes alongside list/detail routes
509
+ - **ALWAYS** use `navigate('create')` or `navigate(\`${id}/edit\`)` from list/detail pages
510
+ - **ALWAYS** include a back button that uses `navigate(-1)` to return to previous page
511
+
512
+ **FORBIDDEN:**
513
+ ```tsx
514
+ // WRONG: modal for create form
515
+ const [showCreateModal, setShowCreateModal] = useState(false);
516
+ <Modal open={showCreateModal}><CreateForm /></Modal>
517
+
518
+ // WRONG: dialog for edit form
519
+ <Dialog open={editDialogOpen}><EditForm entity={selected} /></Dialog>
520
+
521
+ // WRONG: drawer for form
522
+ <Drawer open={isDrawerOpen}><SmartForm /></Drawer>
523
+
524
+ // WRONG: inline form toggle
525
+ {isEditing ? <EditForm /> : <DetailView />}
526
+ ```
527
+
528
+ ---
529
+
530
+ ## 4. CSS Variables (Theme System)
531
+
532
+ > **NEVER use hardcoded Tailwind colors.** ALWAYS use CSS variables for theme support.
533
+
534
+ ### Variable Reference
535
+
536
+ | Usage | CSS Variable | Example |
537
+ |-------|-------------|---------|
538
+ | Background | `var(--bg-primary)` | `bg-[var(--bg-primary)]` |
539
+ | Card background | `var(--bg-card)` | `bg-[var(--bg-card)]` |
540
+ | Text primary | `var(--text-primary)` | `text-[var(--text-primary)]` |
541
+ | Text secondary | `var(--text-secondary)` | `text-[var(--text-secondary)]` |
542
+ | Borders | `var(--border-color)` | `border-[var(--border-color)]` |
543
+ | Accent | `var(--color-accent-500)` | `text-[var(--color-accent-500)]` |
544
+ | Card radius | `var(--radius-card)` | `style={{ borderRadius: 'var(--radius-card)' }}` |
545
+
546
+ ### Card Pattern
547
+
548
+ ```tsx
549
+ <div
550
+ className="bg-[var(--bg-card)] border border-[var(--border-color)] p-6"
551
+ style={{ borderRadius: 'var(--radius-card)' }}
552
+ >
553
+ <h2 className="text-lg font-semibold text-[var(--text-primary)]">Title</h2>
554
+ <p className="text-sm text-[var(--text-secondary)]">Description</p>
555
+ </div>
556
+ ```
557
+
558
+ **FORBIDDEN:**
559
+ ```tsx
560
+ // WRONG: hardcoded Tailwind colors
561
+ className="bg-white border-gray-200 text-gray-900"
562
+
563
+ // WRONG: hardcoded hex/rgb
564
+ style={{ backgroundColor: '#ffffff', color: '#1a1a1a' }}
565
+ ```
566
+
567
+ ---
568
+
569
+ ## 5. Component Rules
570
+
571
+ | Need | Component | Source |
572
+ |------|-----------|--------|
573
+ | Data table | `SmartTable` | `@/components/SmartTable` |
574
+ | Filters | `SmartFilter` | `@/components/SmartFilter` |
575
+ | Entity cards | `EntityCard` | `@/components/EntityCard` |
576
+ | Forms | `SmartForm` | `@/components/SmartForm` |
577
+ | FK field lookup | `EntityLookup` | `@/components/ui/EntityLookup` |
578
+ | Statistics | `StatCard` | `@/components/StatCard` |
579
+ | Loading spinner | `Loader2` | `lucide-react` |
580
+ | Page loader | `PageLoader` | `@/components/ui/PageLoader` |
581
+
582
+ ### Rules
583
+
584
+ - **NEVER** use raw `<table>` — use SmartTable
585
+ - **NEVER** create custom spinners — use `Loader2` from lucide-react
586
+ - **NEVER** import axios directly — use `@/services/api/apiClient`
587
+ - **ALWAYS** use `PageLoader` as Suspense fallback
588
+ - **ALWAYS** use existing shared components before creating new ones
589
+
590
+ ---
591
+
592
+ ## 6. Foreign Key Fields & Entity Lookup (CRITICAL)
593
+
594
+ > **NEVER render a foreign key (Guid) as a plain text input.** FK fields MUST use a searchable lookup component.
595
+ > A form asking the user to type a GUID manually is a UX failure. ALL FK fields must provide entity search & selection.
596
+
597
+ ### Field Type Classification
598
+
599
+ When generating form fields, determine the field type from the entity property:
600
+
601
+ | Property type | Form field type | Component |
602
+ |---------------|----------------|-----------|
603
+ | `string` | Text input | `<input type="text" />` |
604
+ | `string?` | Text input (optional) | `<input type="text" />` |
605
+ | `Guid` (FK — e.g., `EmployeeId`, `DepartmentId`) | **Entity Lookup** | `<EntityLookup />` |
606
+ | `bool` | Toggle/Checkbox | `<input type="checkbox" />` |
607
+ | `int` / `decimal` | Number input | `<input type="number" />` |
608
+ | `DateTime` | Date picker | `<input type="date" />` |
609
+ | `enum` | Select dropdown | `<select>` |
610
+
611
+ **How to detect FK fields:** Any property named `{Entity}Id` of type `Guid` that has a corresponding navigation property is a foreign key. Examples: `EmployeeId`, `DepartmentId`, `CategoryId`, `ParentId`.
612
+
613
+ ### EntityLookup Component Pattern
614
+
615
+ ```tsx
616
+ import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
617
+ import { useTranslation } from 'react-i18next';
618
+ import { Search, X, ChevronDown } from 'lucide-react';
619
+ import { apiClient } from '@/services/api/apiClient';
620
+
621
+ interface EntityLookupOption {
622
+ id: string;
623
+ label: string; // Display name (e.g., employee full name)
624
+ sublabel?: string; // Secondary info (e.g., department, code)
625
+ }
626
+
627
+ interface EntityLookupProps {
628
+ /** API endpoint to search entities (e.g., '/api/business/human-resources/employees') */
629
+ apiEndpoint: string;
630
+ /** Currently selected entity ID */
631
+ value: string | null;
632
+ /** Callback when entity is selected */
633
+ onChange: (id: string | null) => void;
634
+ /** Field label */
635
+ label: string;
636
+ /** Placeholder text */
637
+ placeholder?: string;
638
+ /** Map API response item to display option */
639
+ mapOption: (item: any) => EntityLookupOption;
640
+ /** Whether the field is required */
641
+ required?: boolean;
642
+ /** Whether the field is disabled */
643
+ disabled?: boolean;
644
+ /** Error message to display */
645
+ error?: string;
646
+ }
647
+
648
+ export function EntityLookup({
649
+ apiEndpoint,
650
+ value,
651
+ onChange,
652
+ label,
653
+ placeholder,
654
+ mapOption,
655
+ required = false,
656
+ disabled = false,
657
+ error,
658
+ }: EntityLookupProps) {
659
+ const { t } = useTranslation(['common']);
660
+ const [search, setSearch] = useState('');
661
+ const [options, setOptions] = useState<EntityLookupOption[]>([]);
662
+ const [selectedOption, setSelectedOption] = useState<EntityLookupOption | null>(null);
663
+ const [isOpen, setIsOpen] = useState(false);
664
+ const [loading, setLoading] = useState(false);
665
+ const containerRef = useRef<HTMLDivElement>(null);
666
+ const debounceRef = useRef<ReturnType<typeof setTimeout>>();
667
+
668
+ // Load selected entity display on mount (when value is set but no label)
669
+ useEffect(() => {
670
+ if (value && !selectedOption) {
671
+ apiClient.get(`${apiEndpoint}/${value}`)
672
+ .then(res => setSelectedOption(mapOption(res.data)))
673
+ .catch(() => { /* Entity not found — clear */ });
674
+ }
675
+ }, [value, apiEndpoint, mapOption, selectedOption]);
676
+
677
+ // Debounced search — 300ms delay, minimum 2 characters
678
+ const handleSearch = useCallback((term: string) => {
679
+ setSearch(term);
680
+ if (debounceRef.current) clearTimeout(debounceRef.current);
681
+
682
+ if (term.length < 2) {
683
+ setOptions([]);
684
+ return;
685
+ }
686
+
687
+ debounceRef.current = setTimeout(async () => {
688
+ setLoading(true);
689
+ try {
690
+ const res = await apiClient.get(apiEndpoint, {
691
+ params: { search: term, pageSize: 20 },
692
+ });
693
+ setOptions((res.data.items || res.data).map(mapOption));
694
+ } catch {
695
+ setOptions([]);
696
+ } finally {
697
+ setLoading(false);
698
+ }
699
+ }, 300);
700
+ }, [apiEndpoint, mapOption]);
701
+
702
+ // Load initial options when dropdown opens (show first 20)
703
+ const handleOpen = useCallback(async () => {
704
+ if (disabled) return;
705
+ setIsOpen(true);
706
+ if (options.length === 0 && search.length < 2) {
707
+ setLoading(true);
708
+ try {
709
+ const res = await apiClient.get(apiEndpoint, {
710
+ params: { pageSize: 20 },
711
+ });
712
+ setOptions((res.data.items || res.data).map(mapOption));
713
+ } catch {
714
+ setOptions([]);
715
+ } finally {
716
+ setLoading(false);
717
+ }
718
+ }
719
+ }, [disabled, apiEndpoint, mapOption, options.length, search.length]);
720
+
721
+ // Select entity
722
+ const handleSelect = useCallback((option: EntityLookupOption) => {
723
+ setSelectedOption(option);
724
+ onChange(option.id);
725
+ setIsOpen(false);
726
+ setSearch('');
727
+ }, [onChange]);
728
+
729
+ // Clear selection
730
+ const handleClear = useCallback(() => {
731
+ setSelectedOption(null);
732
+ onChange(null);
733
+ setSearch('');
734
+ }, [onChange]);
735
+
736
+ // Close on outside click
737
+ useEffect(() => {
738
+ const handleClickOutside = (e: MouseEvent) => {
739
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
740
+ setIsOpen(false);
741
+ }
742
+ };
743
+ document.addEventListener('mousedown', handleClickOutside);
744
+ return () => document.removeEventListener('mousedown', handleClickOutside);
745
+ }, []);
746
+
747
+ return (
748
+ <div ref={containerRef} className="relative">
749
+ <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
750
+ {label} {required && <span className="text-[var(--error-text)]">*</span>}
751
+ </label>
752
+
753
+ {/* Selected value display OR search input */}
754
+ {selectedOption && !isOpen ? (
755
+ <div className="flex items-center gap-2 px-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)]">
756
+ <div className="flex-1">
757
+ <span className="text-[var(--text-primary)]">{selectedOption.label}</span>
758
+ {selectedOption.sublabel && (
759
+ <span className="ml-2 text-sm text-[var(--text-secondary)]">{selectedOption.sublabel}</span>
760
+ )}
761
+ </div>
762
+ {!disabled && (
763
+ <button type="button" onClick={handleClear} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
764
+ <X className="w-4 h-4" />
765
+ </button>
766
+ )}
767
+ <button type="button" onClick={handleOpen} className="text-[var(--text-secondary)]">
768
+ <ChevronDown className="w-4 h-4" />
769
+ </button>
770
+ </div>
771
+ ) : (
772
+ <div className="relative">
773
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" />
774
+ <input
775
+ type="text"
776
+ value={search}
777
+ onChange={(e) => handleSearch(e.target.value)}
778
+ onFocus={handleOpen}
779
+ placeholder={placeholder || t('common:actions.search', 'Search...')}
780
+ disabled={disabled}
781
+ className="w-full pl-9 pr-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:ring-2 focus:ring-[var(--color-accent-500)] focus:border-transparent"
782
+ />
783
+ </div>
784
+ )}
785
+
786
+ {/* Dropdown */}
787
+ {isOpen && (
788
+ <div className="absolute z-50 w-full mt-1 bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] shadow-lg max-h-60 overflow-auto">
789
+ {loading ? (
790
+ <div className="p-3 text-center text-[var(--text-secondary)]">
791
+ {t('common:actions.loading', 'Loading...')}
792
+ </div>
793
+ ) : options.length === 0 ? (
794
+ <div className="p-3 text-center text-[var(--text-secondary)]">
795
+ {search.length < 2
796
+ ? t('common:actions.typeToSearch', 'Type at least 2 characters to search...')
797
+ : t('common:empty.noResults', 'No results found')}
798
+ </div>
799
+ ) : (
800
+ options.map((option) => (
801
+ <button
802
+ key={option.id}
803
+ type="button"
804
+ onClick={() => handleSelect(option)}
805
+ className="w-full px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
806
+ >
807
+ <div className="text-[var(--text-primary)]">{option.label}</div>
808
+ {option.sublabel && (
809
+ <div className="text-sm text-[var(--text-secondary)]">{option.sublabel}</div>
810
+ )}
811
+ </button>
812
+ ))
813
+ )}
814
+ </div>
815
+ )}
816
+
817
+ {/* Error message */}
818
+ {error && (
819
+ <p className="mt-1 text-sm text-[var(--error-text)]">{error}</p>
820
+ )}
821
+ </div>
822
+ );
823
+ }
824
+ ```
825
+
826
+ ### Usage in Form Pages
827
+
828
+ ```tsx
829
+ // In EntityCreatePage.tsx or EntityEditPage.tsx
830
+ import { EntityLookup } from '@/components/ui/EntityLookup';
831
+
832
+ // Inside the form:
833
+ <EntityLookup
834
+ apiEndpoint="/api/business/human-resources/employees"
835
+ value={formData.employeeId}
836
+ onChange={(id) => handleChange('employeeId', id)}
837
+ label={t('module:form.employee', 'Employee')}
838
+ placeholder={t('module:form.employeePlaceholder', 'Search for an employee...')}
839
+ mapOption={(emp) => ({
840
+ id: emp.id,
841
+ label: `${emp.firstName} ${emp.lastName}`,
842
+ sublabel: emp.department || emp.code,
843
+ })}
844
+ required
845
+ error={errors.employeeId}
846
+ />
847
+
848
+ // For DepartmentId FK:
849
+ <EntityLookup
850
+ apiEndpoint="/api/business/human-resources/departments"
851
+ value={formData.departmentId}
852
+ onChange={(id) => handleChange('departmentId', id)}
853
+ label={t('module:form.department', 'Department')}
854
+ placeholder={t('module:form.departmentPlaceholder', 'Search for a department...')}
855
+ mapOption={(dept) => ({
856
+ id: dept.id,
857
+ label: dept.name,
858
+ sublabel: dept.code,
859
+ })}
860
+ required
861
+ />
862
+ ```
863
+
864
+ ### API Search Endpoint Convention (Backend)
865
+
866
+ For EntityLookup to work, each entity's API MUST support search via query parameter:
867
+
868
+ ```
869
+ GET /api/{resource}?search={term}&pageSize=20
870
+ ```
871
+
872
+ Response format:
873
+ ```json
874
+ {
875
+ "items": [
876
+ { "id": "guid", "code": "EMP001", "name": "John Doe", ... }
877
+ ],
878
+ "totalCount": 42
879
+ }
880
+ ```
881
+
882
+ The backend service's `GetAllAsync` method should accept search parameters:
883
+
884
+ ```csharp
885
+ public async Task<PaginatedResult<EntityResponseDto>> GetAllAsync(
886
+ string? search = null,
887
+ int page = 1,
888
+ int pageSize = 20,
889
+ CancellationToken ct = default)
890
+ {
891
+ var query = _db.Entities
892
+ .Where(x => x.TenantId == _currentUser.TenantId);
893
+
894
+ if (!string.IsNullOrWhiteSpace(search))
895
+ {
896
+ query = query.Where(x =>
897
+ x.Name.Contains(search) ||
898
+ x.Code.Contains(search));
899
+ }
900
+
901
+ var totalCount = await query.CountAsync(ct);
902
+ var items = await query
903
+ .OrderBy(x => x.Name)
904
+ .Skip((page - 1) * pageSize)
905
+ .Take(pageSize)
906
+ .Select(x => new EntityResponseDto { ... })
907
+ .ToListAsync(ct);
908
+
909
+ return new PaginatedResult<EntityResponseDto>(items, totalCount, page, pageSize);
910
+ }
911
+ ```
912
+
913
+ ### Rules
914
+
915
+ - **NEVER** render a `Guid` FK field as `<input type="text">` — always use `EntityLookup`
916
+ - **NEVER** ask the user to manually type or paste a GUID/ID
917
+ - **ALWAYS** provide a search-based selection for FK fields
918
+ - **ALWAYS** show the entity's display name (Name, FullName, Code+Name) not the GUID
919
+ - **ALWAYS** include `mapOption` to define how the related entity is displayed
920
+ - **ALWAYS** load the selected entity's display name on mount (for edit forms)
921
+ - **ALWAYS** support clearing the selection (unless required + already set)
922
+
923
+ **FORBIDDEN:**
924
+ ```tsx
925
+ // WRONG: Plain text input for FK field
926
+ <input
927
+ type="text"
928
+ value={formData.employeeId}
929
+ onChange={(e) => handleChange('employeeId', e.target.value)}
930
+ placeholder="Enter Employee ID..."
931
+ />
932
+
933
+ // WRONG: Raw GUID displayed to user
934
+ <span>{entity.departmentId}</span>
935
+
936
+ // WRONG: Select with hardcoded options for FK
937
+ <select onChange={(e) => handleChange('departmentId', e.target.value)}>
938
+ <option value="guid-1">Department A</option>
939
+ </select>
940
+ ```
941
+
942
+ ### I18n Keys for EntityLookup
943
+
944
+ Add these keys to the module's translation files:
945
+
946
+ ```json
947
+ {
948
+ "form": {
949
+ "employee": "Employee",
950
+ "employeePlaceholder": "Search for an employee...",
951
+ "department": "Department",
952
+ "departmentPlaceholder": "Search for a department..."
953
+ }
954
+ }
955
+ ```
956
+
957
+ ---
958
+
959
+ ## 7. Checklist for /apex Frontend Execution
960
+
961
+ Before marking frontend tasks as complete, verify:
962
+
963
+ - [ ] All page imports use `React.lazy()` with named export wrapping
964
+ - [ ] `<Suspense fallback={<PageLoader />}>` wraps all lazy components in routes
965
+ - [ ] Translation files exist for **all 4 languages** (fr, en, it, de)
966
+ - [ ] All `t()` calls include namespace prefix AND fallback value
967
+ - [ ] No hardcoded strings in JSX — all text goes through `t()`
968
+ - [ ] CSS uses variables only — no hardcoded Tailwind colors
969
+ - [ ] Pages follow loading → error → content pattern
970
+ - [ ] Pages use `src/pages/{Context}/{App}/{Module}/` hierarchy
971
+ - [ ] API calls use generated hooks or `apiClient` (never raw axios)
972
+ - [ ] Components use SmartTable/SmartFilter/EntityCard (never raw HTML tables)
973
+ - [ ] **FK fields use `EntityLookup` — ZERO plain text inputs for Guid FK fields**
974
+ - [ ] **All FK fields have `mapOption` showing display name, not GUID**
975
+ - [ ] **Backend APIs support `?search=` query parameter for EntityLookup**
976
+ - [ ] **Create/Edit forms are full pages with own routes — ZERO modals/popups/drawers**
977
+ - [ ] `EntityCreatePage.tsx` exists with route `/{module}/create`
978
+ - [ ] `EntityEditPage.tsx` exists with route `/{module}/:id/edit`
979
+ - [ ] No `<Modal>`, `<Dialog>`, `<Drawer>` imports in form-related pages
980
+ - [ ] Form pages include back button with `navigate(-1)`
981
+ - [ ] Form pages are covered by frontend tests (see section 8)
982
+
983
+ ---
984
+
985
+ ## 8. Frontend Form Testing
986
+
987
+ > **ALL form pages MUST have tests.** Forms are critical user interaction points and MUST be verified.
988
+
989
+ ### Required Test Coverage per Form Page
990
+
991
+ | Test category | What to verify | Tool |
992
+ |---------------|---------------|------|
993
+ | Rendering | Form renders with all expected fields | Vitest + React Testing Library |
994
+ | Validation | Required fields show errors on empty submit | Vitest + React Testing Library |
995
+ | Submission | Successful submit calls API and navigates back | Vitest + MSW (mock API) |
996
+ | Pre-fill (edit) | Edit form loads entity data into fields | Vitest + React Testing Library |
997
+ | Navigation | Back button calls `navigate(-1)` | Vitest + React Testing Library |
998
+ | Error handling | API error displays error message | Vitest + MSW |
999
+
1000
+ ### Test File Convention
1001
+
1002
+ ```
1003
+ src/pages/{Context}/{App}/{Module}/
1004
+ ├── EntityCreatePage.tsx
1005
+ ├── EntityCreatePage.test.tsx ← MANDATORY
1006
+ ├── EntityEditPage.tsx
1007
+ ├── EntityEditPage.test.tsx ← MANDATORY
1008
+ ├── EntityListPage.tsx
1009
+ └── EntityDetailPage.tsx
1010
+ ```
1011
+
1012
+ ### Create Page Test Template
1013
+
1014
+ ```tsx
1015
+ import { render, screen, waitFor } from '@testing-library/react';
1016
+ import userEvent from '@testing-library/user-event';
1017
+ import { MemoryRouter } from 'react-router-dom';
1018
+ import { describe, it, expect, vi } from 'vitest';
1019
+ import { EntityCreatePage } from './EntityCreatePage';
1020
+
1021
+ // Mock API
1022
+ vi.mock('@/services/api/apiClient');
1023
+ const mockNavigate = vi.fn();
1024
+ vi.mock('react-router-dom', async () => ({
1025
+ ...(await vi.importActual('react-router-dom')),
1026
+ useNavigate: () => mockNavigate,
1027
+ }));
1028
+
1029
+ describe('EntityCreatePage', () => {
1030
+ it('renders the create form with all fields', () => {
1031
+ render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
1032
+ expect(screen.getByRole('textbox', { name: /name/i })).toBeInTheDocument();
1033
+ // Verify all expected form fields
1034
+ });
1035
+
1036
+ it('shows validation errors on empty submit', async () => {
1037
+ render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
1038
+ await userEvent.click(screen.getByRole('button', { name: /save|create/i }));
1039
+ await waitFor(() => {
1040
+ expect(screen.getByText(/required/i)).toBeInTheDocument();
1041
+ });
1042
+ });
1043
+
1044
+ it('submits form and navigates back on success', async () => {
1045
+ render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
1046
+ await userEvent.type(screen.getByRole('textbox', { name: /name/i }), 'Test');
1047
+ await userEvent.click(screen.getByRole('button', { name: /save|create/i }));
1048
+ await waitFor(() => {
1049
+ expect(mockNavigate).toHaveBeenCalledWith(-1);
1050
+ });
1051
+ });
1052
+
1053
+ it('navigates back on cancel/back button', async () => {
1054
+ render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
1055
+ await userEvent.click(screen.getByRole('button', { name: /back|cancel/i }));
1056
+ expect(mockNavigate).toHaveBeenCalledWith(-1);
1057
+ });
1058
+ });
1059
+ ```
1060
+
1061
+ ### Edit Page Test Template
1062
+
1063
+ ```tsx
1064
+ describe('EntityEditPage', () => {
1065
+ it('loads entity data and pre-fills the form', async () => {
1066
+ render(<MemoryRouter initialEntries={['/entities/123/edit']}><EntityEditPage /></MemoryRouter>);
1067
+ await waitFor(() => {
1068
+ expect(screen.getByDisplayValue('Existing Name')).toBeInTheDocument();
1069
+ });
1070
+ });
1071
+
1072
+ it('submits updated data and navigates back', async () => {
1073
+ render(<MemoryRouter initialEntries={['/entities/123/edit']}><EntityEditPage /></MemoryRouter>);
1074
+ await waitFor(() => screen.getByDisplayValue('Existing Name'));
1075
+ await userEvent.clear(screen.getByRole('textbox', { name: /name/i }));
1076
+ await userEvent.type(screen.getByRole('textbox', { name: /name/i }), 'Updated');
1077
+ await userEvent.click(screen.getByRole('button', { name: /save/i }));
1078
+ await waitFor(() => {
1079
+ expect(mockNavigate).toHaveBeenCalledWith(-1);
1080
+ });
1081
+ });
1082
+
1083
+ it('displays error when API call fails', async () => {
1084
+ // Mock API to reject
1085
+ render(<MemoryRouter initialEntries={['/entities/123/edit']}><EntityEditPage /></MemoryRouter>);
1086
+ // ... trigger submit with mocked failure
1087
+ await waitFor(() => {
1088
+ expect(screen.getByText(/failed/i)).toBeInTheDocument();
1089
+ });
1090
+ });
1091
+ });
1092
+ ```
1093
+
1094
+ ### Rules
1095
+
1096
+ - **EVERY** `EntityCreatePage.tsx` MUST have a companion `EntityCreatePage.test.tsx`
1097
+ - **EVERY** `EntityEditPage.tsx` MUST have a companion `EntityEditPage.test.tsx`
1098
+ - Tests MUST cover: rendering, validation, submit success, submit error, navigation
1099
+ - Use `@testing-library/react` + `@testing-library/user-event` (NEVER enzyme)
1100
+ - Mock API with `vi.mock()` or MSW — NEVER make real API calls in tests
1101
+ - Test files live next to their component (co-located, NOT in a separate `__tests__/` folder)