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