@atlashub/smartstack-cli 4.18.0 → 4.20.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 (164) hide show
  1. package/package.json +1 -1
  2. package/templates/agents/ba-reader.md +86 -80
  3. package/templates/agents/ba-writer.md +318 -415
  4. package/templates/agents/docs-context-reader.md +3 -3
  5. package/templates/mcp-scaffolding/frontend/nav-routes.ts.hbs +133 -0
  6. package/templates/mcp-scaffolding/frontend/routes.tsx.hbs +126 -0
  7. package/templates/skills/apex/SKILL.md +29 -16
  8. package/templates/skills/apex/_shared.md +62 -9
  9. package/templates/skills/apex/references/analysis-methods.md +8 -6
  10. package/templates/skills/apex/references/challenge-questions.md +5 -5
  11. package/templates/skills/apex/references/core-seed-data.md +68 -45
  12. package/templates/skills/apex/references/frontend-route-wiring-app-tsx.md +26 -21
  13. package/templates/skills/apex/references/parallel-execution.md +156 -0
  14. package/templates/skills/apex/references/person-extension-pattern.md +12 -12
  15. package/templates/skills/apex/references/post-checks.md +1748 -1726
  16. package/templates/skills/apex/references/smartstack-api.md +63 -57
  17. package/templates/skills/apex/references/smartstack-frontend-compliance.md +594 -0
  18. package/templates/skills/apex/references/smartstack-frontend.md +1246 -1842
  19. package/templates/skills/apex/references/smartstack-layers.md +98 -145
  20. package/templates/skills/apex/steps/step-00-init.md +30 -6
  21. package/templates/skills/apex/steps/step-01-analyze.md +27 -23
  22. package/templates/skills/apex/steps/step-02-plan.md +12 -12
  23. package/templates/skills/apex/steps/step-03-execute.md +198 -143
  24. package/templates/skills/apex/steps/step-04-examine.md +24 -93
  25. package/templates/skills/apex/steps/step-05-deep-review.md +16 -16
  26. package/templates/skills/apex/steps/step-06-resolve.md +9 -9
  27. package/templates/skills/apex/steps/step-07-tests.md +3 -1
  28. package/templates/skills/apex/steps/step-08-run-tests.md +1 -1
  29. package/templates/skills/business-analyse/SKILL.md +182 -301
  30. package/templates/skills/business-analyse/_shared.md +119 -336
  31. package/templates/skills/business-analyse/html/ba-interactive.html +706 -85
  32. package/templates/skills/business-analyse/html/build-html.js +41 -3
  33. package/templates/skills/business-analyse/html/src/partials/cadrage-context.html +34 -0
  34. package/templates/skills/business-analyse/html/src/partials/cadrage-risks.html +48 -0
  35. package/templates/skills/business-analyse/html/src/partials/cadrage-scope.html +49 -0
  36. package/templates/skills/business-analyse/html/src/partials/cadrage-stakeholders.html +55 -0
  37. package/templates/skills/business-analyse/html/src/partials/cadrage-success.html +34 -0
  38. package/templates/skills/business-analyse/html/src/partials/consol-datamodel.html +8 -0
  39. package/templates/skills/business-analyse/html/src/partials/consol-flows.html +29 -0
  40. package/templates/skills/business-analyse/html/src/partials/consol-interactions.html +8 -0
  41. package/templates/skills/business-analyse/html/src/partials/consol-permissions.html +8 -0
  42. package/templates/skills/business-analyse/html/src/partials/decomp-dependencies.html +38 -0
  43. package/templates/skills/business-analyse/html/src/partials/decomp-modules.html +51 -0
  44. package/templates/skills/business-analyse/html/src/partials/handoff-summary.html +24 -0
  45. package/templates/skills/business-analyse/html/src/partials/module-spec-container.html +4 -0
  46. package/templates/skills/business-analyse/html/src/scripts/01-data-init.js +17 -1
  47. package/templates/skills/business-analyse/html/src/scripts/02-navigation.js +32 -6
  48. package/templates/skills/business-analyse/html/src/scripts/05-render-specs.js +100 -63
  49. package/templates/skills/business-analyse/html/src/scripts/06-render-mockups.js +372 -0
  50. package/templates/skills/business-analyse/html/src/scripts/07-render-handoff.js +1 -1
  51. package/templates/skills/business-analyse/html/src/scripts/10-comments.js +41 -13
  52. package/templates/skills/business-analyse/html/src/styles/09-mockups-html.css +136 -0
  53. package/templates/skills/business-analyse/html/src/template.html +1 -1
  54. package/templates/skills/business-analyse/patterns/suggestion-catalog.md +7 -5
  55. package/templates/skills/business-analyse/questionnaire/01-context.md +11 -157
  56. package/templates/skills/business-analyse/questionnaire/02-stakeholders-scope.md +101 -0
  57. package/templates/skills/business-analyse/questionnaire/03-data-ui.md +92 -0
  58. package/templates/skills/business-analyse/questionnaire/04-risks-metrics.md +6 -0
  59. package/templates/skills/business-analyse/questionnaire/05-cross-module.md +69 -0
  60. package/templates/skills/business-analyse/questionnaire.md +22 -280
  61. package/templates/skills/business-analyse/react/application-viewer.md +2 -2
  62. package/templates/skills/business-analyse/react/components.md +4 -4
  63. package/templates/skills/business-analyse/react/i18n-template.md +1 -1
  64. package/templates/skills/business-analyse/react/schema.md +14 -14
  65. package/templates/skills/business-analyse/references/acceptance-criteria.md +21 -21
  66. package/templates/skills/business-analyse/references/analysis-semantic-checks.md +3 -3
  67. package/templates/skills/business-analyse/references/compilation-structure-cards.md +1 -1
  68. package/templates/skills/business-analyse/references/consolidation-structural-checks.md +5 -5
  69. package/templates/skills/business-analyse/references/deploy-data-build.md +12 -11
  70. package/templates/skills/business-analyse/references/deploy-modes.md +10 -10
  71. package/templates/skills/business-analyse/references/detection-strategies.md +6 -6
  72. package/templates/skills/business-analyse/references/html-data-mapping.md +15 -15
  73. package/templates/skills/business-analyse/references/naming-conventions.md +4 -4
  74. package/templates/skills/business-analyse/references/review-data-mapping.md +29 -29
  75. package/templates/skills/business-analyse/references/robustness-checks.md +36 -36
  76. package/templates/skills/business-analyse/references/spec-auto-inference.md +2 -2
  77. package/templates/skills/business-analyse/references/ui-dashboard-spec.md +1 -1
  78. package/templates/skills/business-analyse/references/ui-resource-cards.md +1 -1
  79. package/templates/skills/business-analyse/references/validation-checklist.md +3 -3
  80. package/templates/skills/business-analyse/references/wireframe-svg-style-guide.md +2 -2
  81. package/templates/skills/business-analyse/schemas/application-schema.json +8 -8
  82. package/templates/skills/business-analyse/schemas/feature-schema.json +3 -3
  83. package/templates/skills/business-analyse/schemas/index-schema.json +47 -0
  84. package/templates/skills/business-analyse/schemas/project-schema.json +6 -6
  85. package/templates/skills/business-analyse/schemas/sections/analysis-schema.json +1 -1
  86. package/templates/skills/business-analyse/schemas/sections/handoff-schema.json +5 -3
  87. package/templates/skills/business-analyse/schemas/sections/metadata-schema.json +4 -4
  88. package/templates/skills/business-analyse/schemas/sections/specification-schema.json +1 -1
  89. package/templates/skills/business-analyse/schemas/shared/common-defs.json +4 -4
  90. package/templates/skills/business-analyse/steps/step-00-init.md +68 -77
  91. package/templates/skills/business-analyse/steps/step-01-cadrage.md +50 -216
  92. package/templates/skills/business-analyse/steps/step-02-structure.md +175 -0
  93. package/templates/skills/business-analyse/steps/step-03-specify.md +198 -0
  94. package/templates/skills/business-analyse/steps/step-04-consolidate.md +478 -0
  95. package/templates/skills/business-analyse/steps/step-05-deploy.md +220 -0
  96. package/templates/skills/business-analyse/steps/step-06-review.md +51 -69
  97. package/templates/skills/business-analyse/templates/tpl-frd.md +1 -1
  98. package/templates/skills/business-analyse/templates/tpl-handoff.md +20 -17
  99. package/templates/skills/business-analyse/templates/tpl-launch-displays.md +2 -2
  100. package/templates/skills/business-analyse/templates-react.md +2 -2
  101. package/templates/skills/derive-prd/SKILL.md +92 -0
  102. package/templates/skills/derive-prd/references/acceptance-criteria.md +169 -0
  103. package/templates/skills/derive-prd/references/entity-domain-mapping.md +115 -0
  104. package/templates/skills/{business-analyse → derive-prd}/references/handoff-file-templates.md +131 -120
  105. package/templates/skills/{business-analyse → derive-prd}/references/handoff-mappings.md +95 -95
  106. package/templates/skills/{business-analyse → derive-prd}/references/handoff-seeddata-generation.md +312 -312
  107. package/templates/skills/{business-analyse → derive-prd}/references/prd-generation.md +262 -263
  108. package/templates/skills/derive-prd/references/readiness-scoring.md +104 -0
  109. package/templates/skills/derive-prd/schemas/handoff-schema.json +95 -0
  110. package/templates/skills/derive-prd/steps/step-00-validate.md +130 -0
  111. package/templates/skills/derive-prd/steps/step-01-transform.md +206 -0
  112. package/templates/skills/derive-prd/steps/step-02-export.md +181 -0
  113. package/templates/skills/{business-analyse → derive-prd}/templates/tpl-progress.md +172 -172
  114. package/templates/skills/documentation/SKILL.md +7 -0
  115. package/templates/skills/ralph-loop/SKILL.md +2 -1
  116. package/templates/skills/ralph-loop/references/init-resume-recovery.md +1 -1
  117. package/templates/skills/ralph-loop/steps/step-01-task.md +2 -2
  118. package/templates/skills/apex/references/agent-teams-protocol.md +0 -203
  119. package/templates/skills/business-analyse/_architecture.md +0 -124
  120. package/templates/skills/business-analyse/_elicitation.md +0 -206
  121. package/templates/skills/business-analyse/_module-loop.md +0 -115
  122. package/templates/skills/business-analyse/_rules.md +0 -142
  123. package/templates/skills/business-analyse/_suggestions.md +0 -34
  124. package/templates/skills/business-analyse/questionnaire/00-application.md +0 -160
  125. package/templates/skills/business-analyse/questionnaire/00b-project.md +0 -85
  126. package/templates/skills/business-analyse/questionnaire/02-stakeholders.md +0 -189
  127. package/templates/skills/business-analyse/questionnaire/03-scope.md +0 -164
  128. package/templates/skills/business-analyse/questionnaire/04-data.md +0 -88
  129. package/templates/skills/business-analyse/questionnaire/05-integrations.md +0 -58
  130. package/templates/skills/business-analyse/questionnaire/06-security.md +0 -68
  131. package/templates/skills/business-analyse/questionnaire/07-ui.md +0 -76
  132. package/templates/skills/business-analyse/questionnaire/08-performance.md +0 -42
  133. package/templates/skills/business-analyse/questionnaire/09-constraints.md +0 -45
  134. package/templates/skills/business-analyse/questionnaire/10-documentation.md +0 -58
  135. package/templates/skills/business-analyse/questionnaire/11-data-lifecycle.md +0 -59
  136. package/templates/skills/business-analyse/questionnaire/12-migration.md +0 -58
  137. package/templates/skills/business-analyse/questionnaire/13-cross-module.md +0 -69
  138. package/templates/skills/business-analyse/questionnaire/14-risk-assumptions.md +0 -135
  139. package/templates/skills/business-analyse/questionnaire/15-success-metrics.md +0 -136
  140. package/templates/skills/business-analyse/references/agent-module-prompt.md +0 -366
  141. package/templates/skills/business-analyse/references/agent-pooling-best-practices.md +0 -557
  142. package/templates/skills/business-analyse/references/cache-warming-strategy.md +0 -566
  143. package/templates/skills/business-analyse/references/cadrage-challenge-patterns.md +0 -41
  144. package/templates/skills/business-analyse/references/cadrage-coverage-matrix.md +0 -74
  145. package/templates/skills/business-analyse/references/cadrage-pre-analysis.md +0 -115
  146. package/templates/skills/business-analyse/references/cadrage-shared-modules.md +0 -68
  147. package/templates/skills/business-analyse/references/cadrage-structure-cards.md +0 -85
  148. package/templates/skills/business-analyse/references/team-orchestration.md +0 -1093
  149. package/templates/skills/business-analyse/references/validate-incremental-html.md +0 -121
  150. package/templates/skills/business-analyse/steps/step-01b-applications.md +0 -419
  151. package/templates/skills/business-analyse/steps/step-02-decomposition.md +0 -387
  152. package/templates/skills/business-analyse/steps/step-03a-data.md +0 -16
  153. package/templates/skills/business-analyse/steps/step-03a1-setup.md +0 -486
  154. package/templates/skills/business-analyse/steps/step-03a2-analysis.md +0 -300
  155. package/templates/skills/business-analyse/steps/step-03b-ui.md +0 -405
  156. package/templates/skills/business-analyse/steps/step-03c-compile.md +0 -516
  157. package/templates/skills/business-analyse/steps/step-03d-validate.md +0 -691
  158. package/templates/skills/business-analyse/steps/step-04-consolidation.md +0 -17
  159. package/templates/skills/business-analyse/steps/step-04a-collect.md +0 -415
  160. package/templates/skills/business-analyse/steps/step-04b-analyze.md +0 -163
  161. package/templates/skills/business-analyse/steps/step-04c-decide.md +0 -186
  162. package/templates/skills/business-analyse/steps/step-05a-handoff.md +0 -937
  163. package/templates/skills/business-analyse/steps/step-05b-deploy.md +0 -522
  164. package/templates/skills/business-analyse/steps/step-05c-ralph-readiness.md +0 -703
@@ -1,1842 +1,1246 @@
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/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['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
- - The unified AppLayout component is ALSO lazy-loaded
48
-
49
- **FORBIDDEN:**
50
- ```tsx
51
- // WRONG: static import in route file
52
- import { EmployeesPage } from '@/pages/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 application routes 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/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/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
- ### Namespace Registration (CRITICAL)
191
-
192
- > **After creating i18n JSON files, you MUST register each namespace in the i18n config.**
193
- > Root cause (test-apex-007): JSON files existed but namespaces were not registered → `useTranslation(['module'])` returned empty strings.
194
-
195
- In the i18n config file (`src/i18n/config.ts` or `src/i18n/index.ts`), add each new namespace:
196
-
197
- ```typescript
198
- // Example: registering new module namespaces
199
- import employees from './locales/fr/employees.json';
200
- import projects from './locales/fr/projects.json';
201
- import clients from './locales/fr/clients.json';
202
-
203
- // In resources configuration:
204
- resources: {
205
- fr: { employees, projects, clients, common, navigation },
206
- en: { employees: employeesEn, projects: projectsEn, clients: clientsEn, ... },
207
- // ... it, de
208
- }
209
-
210
- // OR with ns array:
211
- ns: ['common', 'navigation', 'employees', 'projects', 'clients'],
212
- ```
213
-
214
- POST-CHECK 37 validates this. Unregistered namespaces BLOCKING.
215
-
216
- ### Rules
217
-
218
- - **ALWAYS** provide a fallback value as 2nd argument to `t()`
219
- - **ALWAYS** use namespace prefix: `t('namespace:key')`
220
- - **ALWAYS** generate 4 language files (fr, en, it, de) with identical key structures
221
- - **ALWAYS** register new namespaces in i18n config file after creating JSON files
222
- - **NEVER** hardcode user-facing strings in JSX
223
- - **NEVER** use `t('key')` without namespace prefix
224
-
225
- **FORBIDDEN:**
226
- ```tsx
227
- // WRONG: no fallback
228
- t('employees:title')
229
-
230
- // WRONG: no namespace
231
- t('title')
232
-
233
- // WRONG: hardcoded text
234
- <h1>Employees</h1>
235
-
236
- // WRONG: only 2 languages generated
237
- // Must have fr, en, it, de
238
- ```
239
-
240
- ---
241
-
242
- ## 3. Page Structure Pattern
243
-
244
- > **ALL pages MUST follow this structure.** Extracted from SmartStack.app reference implementation.
245
-
246
- ### Standard List Page Template
247
-
248
- ```tsx
249
- import { useState, useCallback, useEffect } from 'react';
250
- import { useTranslation } from 'react-i18next';
251
- import { useNavigate, useParams } from 'react-router-dom';
252
- import { Loader2 } from 'lucide-react';
253
- import { DocToggleButton } from '@/components/docs/DocToggleButton';
254
- import { DataTable } from '@/components/ui/DataTable';
255
-
256
- // API hook (generated by scaffold_api_client)
257
- import { useEntityList } from '@/hooks/useEntity';
258
-
259
- export function EntityListPage() {
260
- // 1. HOOKS — always at the top
261
- const { t } = useTranslation(['{module}']);
262
- const navigate = useNavigate();
263
-
264
- // 2. STATE
265
- const [loading, setLoading] = useState(true);
266
- const [error, setError] = useState<string | null>(null);
267
- const [data, setData] = useState<Entity[]>([]);
268
-
269
- // 3. DATA LOADING (useCallback + useEffect)
270
- const loadData = useCallback(async () => {
271
- try {
272
- setLoading(true);
273
- setError(null);
274
- const result = await entityApi.getAll();
275
- setData(result.items);
276
- } catch (err: any) {
277
- setError(err.message || t('{module}:errors.loadFailed', 'Failed to load data'));
278
- } finally {
279
- setLoading(false);
280
- }
281
- }, [t]);
282
-
283
- useEffect(() => {
284
- loadData();
285
- }, [loadData]);
286
-
287
- // 4. LOADING STATE
288
- if (loading) {
289
- return (
290
- <div className="flex items-center justify-center min-h-[400px]">
291
- <Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
292
- </div>
293
- );
294
- }
295
-
296
- // 5. ERROR STATE
297
- if (error) {
298
- return (
299
- <div className="flex items-center justify-center min-h-[400px]">
300
- <div className="text-center">
301
- <p className="text-[var(--text-secondary)]">{error}</p>
302
- <button
303
- onClick={loadData}
304
- className="mt-4 px-4 py-2 bg-[var(--color-accent-500)] text-white rounded"
305
- >
306
- {t('common:actions.retry', 'Retry')}
307
- </button>
308
- </div>
309
- </div>
310
- );
311
- }
312
-
313
- // 6. CONTENT — create button navigates to /create route
314
- return (
315
- <div className="space-y-6">
316
- {/* Header with DocToggleButton */}
317
- <div className="flex items-center justify-between">
318
- <h1 className="text-2xl font-bold text-[var(--text-primary)]">
319
- {t('{module}:title', 'Module Title')}
320
- </h1>
321
- <div className="flex items-center gap-2">
322
- <DocToggleButton />
323
- <button
324
- onClick={() => navigate('create')}
325
- className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded"
326
- >
327
- {t('{module}:actions.create', 'Create')}
328
- </button>
329
- </div>
330
- </div>
331
-
332
- {/* Content: DataTable with row click → detail */}
333
- {data.length === 0 ? (
334
- <div className="text-center py-12 text-[var(--text-secondary)]">
335
- {t('{module}:empty', 'No items found.')}
336
- </div>
337
- ) : (
338
- <DataTable
339
- data={data}
340
- columns={[
341
- { key: 'name', label: t('{module}:columns.name', 'Name'), sortable: true },
342
- { key: 'code', label: t('{module}:columns.code', 'Code'), sortable: true },
343
- { key: 'status', label: t('{module}:columns.status', 'Status'),
344
- render: (item) => (
345
- <span className={`px-2 py-0.5 rounded text-xs ${
346
- item.isActive
347
- ? 'bg-[var(--success-bg)] text-[var(--success-text)]'
348
- : 'bg-[var(--error-bg)] text-[var(--error-text)]'
349
- }`}>
350
- {item.isActive ? t('common:status.active', 'Active') : t('common:status.inactive', 'Inactive')}
351
- </span>
352
- )
353
- },
354
- ]}
355
- searchable
356
- pagination={{ pageSize: 10 }}
357
- onRowClick={(item) => navigate(`${item.id}`)}
358
- />
359
- )}
360
- </div>
361
- );
362
- }
363
- ```
364
-
365
- ### Detail Page Pattern
366
-
367
- ```tsx
368
- export function EntityDetailPage() {
369
- const { entityId } = useParams<{ entityId: string }>();
370
- const { t } = useTranslation(['{module}']);
371
- const navigate = useNavigate();
372
-
373
- const [entity, setEntity] = useState<Entity | null>(null);
374
- const [loading, setLoading] = useState(true);
375
- const [activeTab, setActiveTab] = useState('info');
376
-
377
- // Lazy tab loading — load data only when tab is first visited
378
- const visitedTabsRef = useRef<Set<string>>(new Set(['info']));
379
-
380
- useEffect(() => {
381
- if (!visitedTabsRef.current.has(activeTab)) {
382
- visitedTabsRef.current.add(activeTab);
383
- // Load tab-specific data here (e.g., fetch leaves for this employee)
384
- }
385
- }, [activeTab]);
386
-
387
- // Edit button navigates to /:id/edit route (NEVER opens a modal)
388
- const handleEdit = () => navigate(`edit`);
389
-
390
- // ... loading/error/content pattern
391
- }
392
- ```
393
-
394
- ### Tab Behavior Rules (CRITICAL)
395
-
396
- > **CRITICAL: Tabs on detail pages switch content LOCALLY they NEVER navigate to other pages.**
397
- > Each tab renders its content INLINE within the same page component.
398
- > Sub-resource data (e.g., an employee's leaves) loads via API call filtered by the parent entity ID.
399
-
400
- **Tab state management:**
401
- - Tabs use `useState<TabKey>('info')` for the active tab — LOCAL React state only
402
- - Tab click handler: `onClick={() => setActiveTab(tabKey)}` — NEVER `navigate()`
403
- - Tab content: conditional rendering `{activeTab === 'tabKey' && <TabContent />}`
404
- - Lazy loading: `visitedTabsRef` tracks which tabs have been visited to avoid redundant API calls
405
-
406
- **Tab content for sub-resources:**
407
- ```tsx
408
- // CORRECT — sub-resource data loaded INLINE within the tab
409
- {activeTab === 'leaves' && (
410
- <div>
411
- <LeaveRequestsTable employeeId={entity.id} />
412
- {/* Optional "View all" link INSIDE the tab content area */}
413
- <Link to={`../leaves?employee=${entity.id}`}>
414
- {t('employees:tabs.viewAllLeaves', 'View all leave requests')}
415
- </Link>
416
- </div>
417
- )}
418
- ```
419
-
420
- **FORBIDDEN tab patterns:**
421
- ```tsx
422
- // FORBIDDEN — tab click handler navigates to another page
423
- const handleTabClick = (tab: TabKey) => {
424
- setActiveTab(tab);
425
- if (tab === 'leaves') navigate(`../leaves?employee=${id}`); // ← BREAKS tab UX
426
- };
427
-
428
- // FORBIDDEN — tab content is empty because navigation already left the page
429
- {activeTab === 'info' && <div>...</div>}
430
- // Leaves tab: nothing renders here, user is already on another page
431
- ```
432
-
433
- **Why this matters:**
434
- - Navigating away loses the detail page context (entity data, scroll position, other tab state)
435
- - Users expect tabs to switch content in-place, not redirect to a different page
436
- - The browser back button should go to the list page, not toggle between tabs
437
-
438
- **POST-CHECK 35 enforces this rule.**
439
-
440
- ---
441
-
442
- ## 3b. Form Pages Pattern (Create / Edit)
443
-
444
- > **CRITICAL: ALL forms MUST be full pages with their own URL route.**
445
- > **NEVER use modals, dialogs, drawers, or popups for create/edit forms.**
446
-
447
- ### Route Convention
448
-
449
- > **CRITICAL:** Route paths MUST use **kebab-case** matching the navigation seed data (which uses `ToKebabCase()`).
450
- > - Single word: `employees` (no change needed)
451
- > - Multi-word: `human-resources`, `time-management` (kebab-case with hyphens)
452
- > - **FORBIDDEN:** `humanresources`, `timemanagement` (concatenated words without hyphens)
453
-
454
- | Action | Route pattern | Page component | File location |
455
- |--------|--------------|----------------|---------------|
456
- | Create | `/{module}/create` | `EntityCreatePage` | `src/pages/{App}/{Module}/EntityCreatePage.tsx` |
457
- | Edit | `/{module}/:id/edit` | `EntityEditPage` | `src/pages/{App}/{Module}/EntityEditPage.tsx` |
458
-
459
- ### Create Page Template
460
-
461
- ```tsx
462
- import { useState } from 'react';
463
- import { useTranslation } from 'react-i18next';
464
- import { useNavigate } from 'react-router-dom';
465
- import { ArrowLeft } from 'lucide-react';
466
- // For FK Guid fields: import { EntityLookup } from '@/components/ui/EntityLookup';
467
-
468
- export function EntityCreatePage() {
469
- const { t } = useTranslation(['{module}']);
470
- const navigate = useNavigate();
471
- const [submitting, setSubmitting] = useState(false);
472
- const [error, setError] = useState<string | null>(null);
473
- const [formData, setFormData] = useState<CreateEntityDto>({
474
- name: '',
475
- // departmentId: '', ← FK Guid field (use EntityLookup below)
476
- });
477
-
478
- const handleSubmit = async (e: React.FormEvent) => {
479
- e.preventDefault();
480
- try {
481
- setSubmitting(true);
482
- setError(null);
483
- await entityApi.create(formData);
484
- navigate(-1); // Back to list
485
- } catch (err: any) {
486
- setError(err.message || t('{module}:errors.createFailed', 'Creation failed'));
487
- } finally {
488
- setSubmitting(false);
489
- }
490
- };
491
-
492
- return (
493
- <div className="space-y-6">
494
- {/* Back button */}
495
- <button
496
- onClick={() => navigate(-1)}
497
- className="flex items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
498
- >
499
- <ArrowLeft className="w-4 h-4" />
500
- {t('common:actions.back', 'Back')}
501
- </button>
502
-
503
- {/* Page title */}
504
- <h1 className="text-2xl font-bold text-[var(--text-primary)]">
505
- {t('{module}:actions.create', 'Create {Entity}')}
506
- </h1>
507
-
508
- {/* Error state */}
509
- {error && (
510
- <div className="p-4 bg-[var(--error-bg)] border border-[var(--error-border)] rounded-[var(--radius-card)]">
511
- <span className="text-[var(--error-text)]">{error}</span>
512
- </div>
513
- )}
514
-
515
- {/* Form NEVER in a modal */}
516
- <form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
517
- {/* Text field */}
518
- <div>
519
- <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
520
- {t('{module}:form.name', 'Name')}
521
- </label>
522
- <input
523
- type="text"
524
- value={formData.name}
525
- onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
526
- 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)]"
527
- required
528
- />
529
- </div>
530
-
531
- {/* FK Guid field — ALWAYS use EntityLookup, NEVER <select> or <input> */}
532
- {/* <div>
533
- <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
534
- {t('{module}:form.department', 'Department')}
535
- </label>
536
- <EntityLookup
537
- apiEndpoint="/api/{app}/{module}/departments"
538
- value={formData.departmentId}
539
- onChange={(id) => setFormData(prev => ({ ...prev, departmentId: id }))}
540
- mapOption={(dept) => ({ label: dept.name, value: dept.id })}
541
- placeholder={t('{module}:form.selectDepartment', 'Select a department...')}
542
- />
543
- </div> */}
544
-
545
- {/* Actions */}
546
- <div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
547
- <button
548
- type="button"
549
- onClick={() => navigate(-1)}
550
- className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]"
551
- >
552
- {t('common:actions.cancel', 'Cancel')}
553
- </button>
554
- <button
555
- type="submit"
556
- disabled={submitting}
557
- 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"
558
- >
559
- {submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
560
- </button>
561
- </div>
562
- </form>
563
- </div>
564
- );
565
- }
566
- ```
567
-
568
- ### Edit Page Template
569
-
570
- ```tsx
571
- import { useState, useEffect, useCallback } from 'react';
572
- import { useTranslation } from 'react-i18next';
573
- import { useNavigate, useParams } from 'react-router-dom';
574
- import { Loader2, ArrowLeft } from 'lucide-react';
575
- // For FK Guid fields: import { EntityLookup } from '@/components/ui/EntityLookup';
576
-
577
- export function EntityEditPage() {
578
- const { entityId } = useParams<{ entityId: string }>();
579
- const { t } = useTranslation(['{module}']);
580
- const navigate = useNavigate();
581
- const [formData, setFormData] = useState<UpdateEntityDto | null>(null);
582
- const [loading, setLoading] = useState(true);
583
- const [submitting, setSubmitting] = useState(false);
584
- const [error, setError] = useState<string | null>(null);
585
-
586
- const loadEntity = useCallback(async () => {
587
- try {
588
- setLoading(true);
589
- const result = await entityApi.getById(entityId!);
590
- setFormData(result);
591
- } catch {
592
- navigate(-1);
593
- } finally {
594
- setLoading(false);
595
- }
596
- }, [entityId, navigate]);
597
-
598
- useEffect(() => { loadEntity(); }, [loadEntity]);
599
-
600
- if (loading || !formData) {
601
- return (
602
- <div className="flex items-center justify-center min-h-[400px]">
603
- <Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
604
- </div>
605
- );
606
- }
607
-
608
- const handleSubmit = async (e: React.FormEvent) => {
609
- e.preventDefault();
610
- try {
611
- setSubmitting(true);
612
- setError(null);
613
- await entityApi.update(entityId!, formData);
614
- navigate(-1); // Back to detail or list
615
- } catch (err: any) {
616
- setError(err.message || t('{module}:errors.updateFailed', 'Update failed'));
617
- } finally {
618
- setSubmitting(false);
619
- }
620
- };
621
-
622
- return (
623
- <div className="space-y-6">
624
- {/* Back button */}
625
- <button
626
- onClick={() => navigate(-1)}
627
- className="flex items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
628
- >
629
- <ArrowLeft className="w-4 h-4" />
630
- {t('common:actions.back', 'Back')}
631
- </button>
632
-
633
- {/* Page title */}
634
- <h1 className="text-2xl font-bold text-[var(--text-primary)]">
635
- {t('{module}:actions.edit', 'Edit {Entity}')}
636
- </h1>
637
-
638
- {/* Error state */}
639
- {error && (
640
- <div className="p-4 bg-[var(--error-bg)] border border-[var(--error-border)] rounded-[var(--radius-card)]">
641
- <span className="text-[var(--error-text)]">{error}</span>
642
- </div>
643
- )}
644
-
645
- {/* Form pre-filled NEVER in a modal */}
646
- <form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
647
- {/* Text field (pre-filled) */}
648
- <div>
649
- <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
650
- {t('{module}:form.name', 'Name')}
651
- </label>
652
- <input
653
- type="text"
654
- value={formData.name}
655
- onChange={(e) => setFormData(prev => prev ? { ...prev, name: e.target.value } : prev)}
656
- 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)]"
657
- required
658
- />
659
- </div>
660
-
661
- {/* FK Guid field — ALWAYS use EntityLookup, NEVER <select> or <input> */}
662
- {/* <div>
663
- <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
664
- {t('{module}:form.department', 'Department')}
665
- </label>
666
- <EntityLookup
667
- apiEndpoint="/api/{app}/{module}/departments"
668
- value={formData.departmentId}
669
- onChange={(id) => setFormData(prev => prev ? { ...prev, departmentId: id } : prev)}
670
- mapOption={(dept) => ({ label: dept.name, value: dept.id })}
671
- placeholder={t('{module}:form.selectDepartment', 'Select a department...')}
672
- />
673
- </div> */}
674
-
675
- {/* Actions */}
676
- <div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
677
- <button
678
- type="button"
679
- onClick={() => navigate(-1)}
680
- className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]"
681
- >
682
- {t('common:actions.cancel', 'Cancel')}
683
- </button>
684
- <button
685
- type="submit"
686
- disabled={submitting}
687
- 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"
688
- >
689
- {submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
690
- </button>
691
- </div>
692
- </form>
693
- </div>
694
- );
695
- }
696
- ```
697
-
698
- ### Lazy Loading for Form Pages
699
-
700
- ```tsx
701
- // In route files — form pages are also lazy-loaded
702
- const EntityCreatePage = lazy(() =>
703
- import('@/pages/HumanResources/Employees/EntityCreatePage')
704
- .then(m => ({ default: m.EntityCreatePage }))
705
- );
706
- const EntityEditPage = lazy(() =>
707
- import('@/pages/HumanResources/Employees/EntityEditPage')
708
- .then(m => ({ default: m.EntityEditPage }))
709
- );
710
-
711
- // Route registration — form pages have their own routes
712
- {
713
- path: 'employees',
714
- children: [
715
- { index: true, element: <Suspense fallback={<PageLoader />}><EmployeesPage /></Suspense> },
716
- { path: 'create', element: <Suspense fallback={<PageLoader />}><EntityCreatePage /></Suspense> },
717
- { path: ':id', element: <Suspense fallback={<PageLoader />}><EntityDetailPage /></Suspense> },
718
- { path: ':id/edit', element: <Suspense fallback={<PageLoader />}><EntityEditPage /></Suspense> },
719
- ]
720
- }
721
-
722
- // Section-level routes — children of the module route (when module has sections)
723
- //
724
- // > **IMPORTANT:** The `list` and `detail` sections do NOT generate additional route entries.
725
- // > They are already covered by the module's `index: true` (list) and `path: ':id'` (detail) routes above.
726
- // > Only sections like `dashboard`, `approve`, `import`, etc. generate the section-kebab child routes below.
727
- // > FORBIDDEN: `path: 'list'`, `path: 'detail'` — these would create unreachable duplicate routes.
728
- //
729
- {
730
- path: '{module-kebab}',
731
- children: [
732
- { index: true, element: <Suspense fallback={<PageLoader />}><{Module}Page /></Suspense> },
733
- { path: 'create', element: <Suspense fallback={<PageLoader />}><Create{Module}Page /></Suspense> },
734
- { path: ':id', element: <Suspense fallback={<PageLoader />}><{Module}DetailPage /></Suspense> },
735
- { path: ':id/edit', element: <Suspense fallback={<PageLoader />}><Edit{Module}Page /></Suspense> },
736
- // Section routes as children of module:
737
- // IMPORTANT: "list" and "detail" are NOT separate path segments.
738
- // - "list" section = already handled by the module's index route above (index: true)
739
- // - "detail" section = already handled by the module's :id route above (path: ':id')
740
- // - Only OTHER sections (dashboard, approve, import, etc.) add path segments:
741
- { path: '{section-kebab}', element: <Suspense fallback={<PageLoader />}><{Section}Page /></Suspense> },
742
- { path: '{section-kebab}/create', element: <Suspense fallback={<PageLoader />}><Create{Section}Page /></Suspense> },
743
- { path: '{section-kebab}/:id', element: <Suspense fallback={<PageLoader />}><{Section}DetailPage /></Suspense> },
744
- { path: '{section-kebab}/:id/edit', element: <Suspense fallback={<PageLoader />}><Edit{Section}Page /></Suspense> },
745
- ]
746
- }
747
-
748
- // PermissionGuard for section-level routes
749
- element: (
750
- <Suspense fallback={<PageLoader />}>
751
- <PermissionGuard permissions={ROUTES['app.module.section'].permissions}>
752
- <SectionPage />
753
- </PermissionGuard>
754
- </Suspense>
755
- )
756
- ```
757
-
758
- ### Rules
759
-
760
- - **NEVER** use `<Modal>`, `<Dialog>`, `<Drawer>`, or `<Popup>` for create/edit forms
761
- - **NEVER** use `useState(isOpen)` to toggle form visibility — forms are pages, not overlays
762
- - **ALWAYS** create a dedicated `EntityCreatePage.tsx` and `EntityEditPage.tsx` page component
763
- - **ALWAYS** register create/edit routes alongside list/detail routes
764
- - **ALWAYS** use `navigate('create')` or `navigate(\`${id}/edit\`)` from list/detail pages
765
- - **ALWAYS** include a back button that uses `navigate(-1)` to return to previous page
766
-
767
- **FORBIDDEN:**
768
- ```tsx
769
- // WRONG: modal for create form
770
- const [showCreateModal, setShowCreateModal] = useState(false);
771
- <Modal open={showCreateModal}><CreateForm /></Modal>
772
-
773
- // WRONG: dialog for edit form
774
- <Dialog open={editDialogOpen}><EditForm entity={selected} /></Dialog>
775
-
776
- // WRONG: drawer for form
777
- <Drawer open={isDrawerOpen}><form>...</form></Drawer>
778
-
779
- // WRONG: inline form toggle
780
- {isEditing ? <EditForm /> : <DetailView />}
781
- ```
782
-
783
- ---
784
-
785
- ## 4. CSS Variables (Theme System)
786
-
787
- > **NEVER use hardcoded Tailwind colors.** ALWAYS use CSS variables for theme support.
788
-
789
- ### Variable Reference
790
-
791
- | Usage | CSS Variable | Example |
792
- |-------|-------------|---------|
793
- | Background | `var(--bg-primary)` | `bg-[var(--bg-primary)]` |
794
- | Card background | `var(--bg-card)` | `bg-[var(--bg-card)]` |
795
- | Text primary | `var(--text-primary)` | `text-[var(--text-primary)]` |
796
- | Text secondary | `var(--text-secondary)` | `text-[var(--text-secondary)]` |
797
- | Borders | `var(--border-color)` | `border-[var(--border-color)]` |
798
- | Accent | `var(--color-accent-500)` | `text-[var(--color-accent-500)]` |
799
- | Card radius | `var(--radius-card)` | `style={{ borderRadius: 'var(--radius-card)' }}` |
800
-
801
- ### Card Pattern
802
-
803
- ```tsx
804
- <div
805
- className="bg-[var(--bg-card)] border border-[var(--border-color)] p-6"
806
- style={{ borderRadius: 'var(--radius-card)' }}
807
- >
808
- <h2 className="text-lg font-semibold text-[var(--text-primary)]">Title</h2>
809
- <p className="text-sm text-[var(--text-secondary)]">Description</p>
810
- </div>
811
- ```
812
-
813
- **FORBIDDEN:**
814
- ```tsx
815
- // WRONG: hardcoded Tailwind colors
816
- className="bg-white border-gray-200 text-gray-900"
817
-
818
- // WRONG: hardcoded hex/rgb
819
- style={{ backgroundColor: '#ffffff', color: '#1a1a1a' }}
820
- ```
821
-
822
- ---
823
-
824
- ## 5. Component Rules
825
-
826
- | Need | Component | Source | Notes |
827
- |------|-----------|--------|-------|
828
- | Data table | `DataTable` | `@/components/ui/DataTable` | Shared component (sorting, pagination, search) |
829
- | Entity cards | `EntityCard` | `@/components/ui/EntityCard` | Shared component (avatar, badges, actions) |
830
- | FK field lookup | `EntityLookup` | Generate in `@/components/ui/EntityLookup` | See section 6 for full pattern |
831
- | KPI statistics | `StatCard` | Generate locally per dashboard | See dashboard-chart.md pattern |
832
- | Chart wrapper | `ChartCard` | Generate locally per dashboard | See dashboard-chart.md pattern |
833
- | Loading spinner | `Loader2` | `lucide-react` | Shared |
834
- | Page loader | `PageLoader` | `@/components/ui/PageLoader` | Shared (Suspense fallback) |
835
- | Docs toggle | `DocToggleButton` | `@/components/docs/DocToggleButton` | Shared |
836
-
837
- ### Rules
838
-
839
- - **NEVER** use raw `<table>` — use `DataTable` from `@/components/ui/DataTable`
840
- - **NEVER** create custom spinners — use `Loader2` from lucide-react
841
- - **NEVER** import axios directly — use `@/services/api/apiClient`
842
- - **ALWAYS** use `PageLoader` as Suspense fallback
843
- - **ALWAYS** use existing shared components before creating new ones
844
- - **ALWAYS** use `EntityLookup` for FK Guid fields (never `<select>` or `<input>` for GUIDs)
845
-
846
- ---
847
-
848
- ## 6. Foreign Key Fields & Entity Lookup (CRITICAL)
849
-
850
- > **NEVER render a foreign key (Guid) as a plain text input.** FK fields MUST use a searchable lookup component.
851
- > A form asking the user to type a GUID manually is a UX failure. ALL FK fields must provide entity search & selection.
852
-
853
- ### Field Type Classification
854
-
855
- When generating form fields, determine the field type from the entity property:
856
-
857
- | Property type | Form field type | Component |
858
- |---------------|----------------|-----------|
859
- | `string` | Text input | `<input type="text" />` |
860
- | `string?` | Text input (optional) | `<input type="text" />` |
861
- | `Guid` (FK — e.g., `EmployeeId`, `DepartmentId`) | **Entity Lookup** | `<EntityLookup />` |
862
- | `bool` | Toggle/Checkbox | `<input type="checkbox" />` |
863
- | `int` / `decimal` | Number input | `<input type="number" />` |
864
- | `DateTime` | Date picker | `<input type="date" />` |
865
- | `enum` | Select dropdown | `<select>` |
866
-
867
- **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`.
868
-
869
- ### EntityLookup Component Pattern
870
-
871
- ```tsx
872
- import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
873
- import { useTranslation } from 'react-i18next';
874
- import { Search, X, ChevronDown } from 'lucide-react';
875
- import { apiClient } from '@/services/api/apiClient';
876
-
877
- interface EntityLookupOption {
878
- id: string;
879
- label: string; // Display name (e.g., employee full name)
880
- sublabel?: string; // Secondary info (e.g., department, code)
881
- }
882
-
883
- interface EntityLookupProps {
884
- /** API endpoint to search entities (e.g., '/api/human-resources/employees') */
885
- apiEndpoint: string;
886
- /** Currently selected entity ID */
887
- value: string | null;
888
- /** Callback when entity is selected */
889
- onChange: (id: string | null) => void;
890
- /** Field label */
891
- label: string;
892
- /** Placeholder text */
893
- placeholder?: string;
894
- /** Map API response item to display option */
895
- mapOption: (item: any) => EntityLookupOption;
896
- /** Whether the field is required */
897
- required?: boolean;
898
- /** Whether the field is disabled */
899
- disabled?: boolean;
900
- /** Error message to display */
901
- error?: string;
902
- }
903
-
904
- export function EntityLookup({
905
- apiEndpoint,
906
- value,
907
- onChange,
908
- label,
909
- placeholder,
910
- mapOption,
911
- required = false,
912
- disabled = false,
913
- error,
914
- }: EntityLookupProps) {
915
- const { t } = useTranslation(['common']);
916
- const [search, setSearch] = useState('');
917
- const [options, setOptions] = useState<EntityLookupOption[]>([]);
918
- const [selectedOption, setSelectedOption] = useState<EntityLookupOption | null>(null);
919
- const [isOpen, setIsOpen] = useState(false);
920
- const [loading, setLoading] = useState(false);
921
- const containerRef = useRef<HTMLDivElement>(null);
922
- const debounceRef = useRef<ReturnType<typeof setTimeout>>();
923
-
924
- // Load selected entity display on mount (when value is set but no label)
925
- useEffect(() => {
926
- if (value && !selectedOption) {
927
- apiClient.get(`${apiEndpoint}/${value}`)
928
- .then(res => setSelectedOption(mapOption(res.data)))
929
- .catch(() => { /* Entity not found — clear */ });
930
- }
931
- }, [value, apiEndpoint, mapOption, selectedOption]);
932
-
933
- // Debounced search 300ms delay, minimum 2 characters
934
- const handleSearch = useCallback((term: string) => {
935
- setSearch(term);
936
- if (debounceRef.current) clearTimeout(debounceRef.current);
937
-
938
- if (term.length < 2) {
939
- setOptions([]);
940
- return;
941
- }
942
-
943
- debounceRef.current = setTimeout(async () => {
944
- setLoading(true);
945
- try {
946
- const res = await apiClient.get(apiEndpoint, {
947
- params: { search: term, pageSize: 20 },
948
- });
949
- setOptions((res.data.items || res.data).map(mapOption));
950
- } catch {
951
- setOptions([]);
952
- } finally {
953
- setLoading(false);
954
- }
955
- }, 300);
956
- }, [apiEndpoint, mapOption]);
957
-
958
- // Load initial options when dropdown opens (show first 20)
959
- const handleOpen = useCallback(async () => {
960
- if (disabled) return;
961
- setIsOpen(true);
962
- if (options.length === 0 && search.length < 2) {
963
- setLoading(true);
964
- try {
965
- const res = await apiClient.get(apiEndpoint, {
966
- params: { pageSize: 20 },
967
- });
968
- setOptions((res.data.items || res.data).map(mapOption));
969
- } catch {
970
- setOptions([]);
971
- } finally {
972
- setLoading(false);
973
- }
974
- }
975
- }, [disabled, apiEndpoint, mapOption, options.length, search.length]);
976
-
977
- // Select entity
978
- const handleSelect = useCallback((option: EntityLookupOption) => {
979
- setSelectedOption(option);
980
- onChange(option.id);
981
- setIsOpen(false);
982
- setSearch('');
983
- }, [onChange]);
984
-
985
- // Clear selection
986
- const handleClear = useCallback(() => {
987
- setSelectedOption(null);
988
- onChange(null);
989
- setSearch('');
990
- }, [onChange]);
991
-
992
- // Close on outside click
993
- useEffect(() => {
994
- const handleClickOutside = (e: MouseEvent) => {
995
- if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
996
- setIsOpen(false);
997
- }
998
- };
999
- document.addEventListener('mousedown', handleClickOutside);
1000
- return () => document.removeEventListener('mousedown', handleClickOutside);
1001
- }, []);
1002
-
1003
- return (
1004
- <div ref={containerRef} className="relative">
1005
- <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
1006
- {label} {required && <span className="text-[var(--error-text)]">*</span>}
1007
- </label>
1008
-
1009
- {/* Selected value display OR search input */}
1010
- {selectedOption && !isOpen ? (
1011
- <div className="flex items-center gap-2 px-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)]">
1012
- <div className="flex-1">
1013
- <span className="text-[var(--text-primary)]">{selectedOption.label}</span>
1014
- {selectedOption.sublabel && (
1015
- <span className="ml-2 text-sm text-[var(--text-secondary)]">{selectedOption.sublabel}</span>
1016
- )}
1017
- </div>
1018
- {!disabled && (
1019
- <button type="button" onClick={handleClear} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
1020
- <X className="w-4 h-4" />
1021
- </button>
1022
- )}
1023
- <button type="button" onClick={handleOpen} className="text-[var(--text-secondary)]">
1024
- <ChevronDown className="w-4 h-4" />
1025
- </button>
1026
- </div>
1027
- ) : (
1028
- <div className="relative">
1029
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" />
1030
- <input
1031
- type="text"
1032
- value={search}
1033
- onChange={(e) => handleSearch(e.target.value)}
1034
- onFocus={handleOpen}
1035
- placeholder={placeholder || t('common:actions.search', 'Search...')}
1036
- disabled={disabled}
1037
- 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"
1038
- />
1039
- </div>
1040
- )}
1041
-
1042
- {/* Dropdown */}
1043
- {isOpen && (
1044
- <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">
1045
- {loading ? (
1046
- <div className="p-3 text-center text-[var(--text-secondary)]">
1047
- {t('common:actions.loading', 'Loading...')}
1048
- </div>
1049
- ) : options.length === 0 ? (
1050
- <div className="p-3 text-center text-[var(--text-secondary)]">
1051
- {search.length < 2
1052
- ? t('common:actions.typeToSearch', 'Type at least 2 characters to search...')
1053
- : t('common:empty.noResults', 'No results found')}
1054
- </div>
1055
- ) : (
1056
- options.map((option) => (
1057
- <button
1058
- key={option.id}
1059
- type="button"
1060
- onClick={() => handleSelect(option)}
1061
- className="w-full px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
1062
- >
1063
- <div className="text-[var(--text-primary)]">{option.label}</div>
1064
- {option.sublabel && (
1065
- <div className="text-sm text-[var(--text-secondary)]">{option.sublabel}</div>
1066
- )}
1067
- </button>
1068
- ))
1069
- )}
1070
- </div>
1071
- )}
1072
-
1073
- {/* Error message */}
1074
- {error && (
1075
- <p className="mt-1 text-sm text-[var(--error-text)]">{error}</p>
1076
- )}
1077
- </div>
1078
- );
1079
- }
1080
- ```
1081
-
1082
- ### Usage in Form Pages
1083
-
1084
- ```tsx
1085
- // In EntityCreatePage.tsx or EntityEditPage.tsx
1086
- import { EntityLookup } from '@/components/ui/EntityLookup';
1087
-
1088
- // Inside the form:
1089
- <EntityLookup
1090
- apiEndpoint="/api/human-resources/employees"
1091
- value={formData.employeeId}
1092
- onChange={(id) => handleChange('employeeId', id)}
1093
- label={t('module:form.employee', 'Employee')}
1094
- placeholder={t('module:form.employeePlaceholder', 'Search for an employee...')}
1095
- mapOption={(emp) => ({
1096
- id: emp.id,
1097
- label: `${emp.firstName} ${emp.lastName}`,
1098
- sublabel: emp.department || emp.code,
1099
- })}
1100
- required
1101
- error={errors.employeeId}
1102
- />
1103
-
1104
- // For DepartmentId FK:
1105
- <EntityLookup
1106
- apiEndpoint="/api/human-resources/departments"
1107
- value={formData.departmentId}
1108
- onChange={(id) => handleChange('departmentId', id)}
1109
- label={t('module:form.department', 'Department')}
1110
- placeholder={t('module:form.departmentPlaceholder', 'Search for a department...')}
1111
- mapOption={(dept) => ({
1112
- id: dept.id,
1113
- label: dept.name,
1114
- sublabel: dept.code,
1115
- })}
1116
- required
1117
- />
1118
- ```
1119
-
1120
- ### API Search Endpoint Convention (Backend)
1121
-
1122
- For EntityLookup to work, each entity's API MUST support search via query parameter:
1123
-
1124
- ```
1125
- GET /api/{resource}?search={term}&pageSize=20
1126
- ```
1127
-
1128
- Response format:
1129
- ```json
1130
- {
1131
- "items": [
1132
- { "id": "guid", "code": "EMP001", "name": "John Doe", ... }
1133
- ],
1134
- "totalCount": 42
1135
- }
1136
- ```
1137
-
1138
- The backend service's `GetAllAsync` method should accept search parameters:
1139
-
1140
- ```csharp
1141
- public async Task<PaginatedResult<EntityResponseDto>> GetAllAsync(
1142
- string? search = null,
1143
- int page = 1,
1144
- int pageSize = 20,
1145
- CancellationToken ct = default)
1146
- {
1147
- var query = _db.Entities
1148
- .Where(x => x.TenantId == _currentUser.TenantId);
1149
-
1150
- if (!string.IsNullOrWhiteSpace(search))
1151
- {
1152
- query = query.Where(x =>
1153
- x.Name.Contains(search) ||
1154
- x.Code.Contains(search));
1155
- }
1156
-
1157
- var totalCount = await query.CountAsync(ct);
1158
- var items = await query
1159
- .OrderBy(x => x.Name)
1160
- .Skip((page - 1) * pageSize)
1161
- .Take(pageSize)
1162
- .Select(x => new EntityResponseDto { ... })
1163
- .ToListAsync(ct);
1164
-
1165
- return new PaginatedResult<EntityResponseDto>(items, totalCount, page, pageSize);
1166
- }
1167
- ```
1168
-
1169
- ### Rules
1170
-
1171
- - **NEVER** render a `Guid` FK field as `<input type="text">` — always use `EntityLookup`
1172
- - **NEVER** render a `Guid` FK field as `<select>` even with API-loaded `<option>` elements, `<select>` is NOT acceptable
1173
- - **NEVER** ask the user to manually type or paste a GUID/ID
1174
- - **ALWAYS** provide a search-based selection via `<EntityLookup />` for FK fields
1175
- - **ALWAYS** show the entity's display name (Name, FullName, Code+Name) not the GUID
1176
- - **ALWAYS** include `mapOption` to define how the related entity is displayed
1177
- - **ALWAYS** load the selected entity's display name on mount (for edit forms)
1178
- - **ALWAYS** support clearing the selection (unless required + already set)
1179
-
1180
- **Why `<select>` is NOT acceptable for FK fields:**
1181
- - `<select>` loads ALL options at once fails with 100+ entities (performance + UX)
1182
- - `<select>` has no search/filter user must scroll through all options
1183
- - `<select>` cannot show sublabels (code, department, etc.)
1184
- - `EntityLookup` provides: debounced API search, paginated results, display name resolution, sublabels
1185
-
1186
- **FORBIDDEN:**
1187
- ```tsx
1188
- // WRONG: Plain text input for FK field
1189
- <input
1190
- type="text"
1191
- value={formData.employeeId}
1192
- onChange={(e) => handleChange('employeeId', e.target.value)}
1193
- placeholder="Enter Employee ID..."
1194
- />
1195
-
1196
- // WRONG: <select> dropdown for FK field (even with API-loaded options)
1197
- <select
1198
- value={formData.departmentId}
1199
- onChange={(e) => setFormData({ ...formData, departmentId: e.target.value })}
1200
- >
1201
- <option value="">Select Department...</option>
1202
- {departments.map((dept) => (
1203
- <option key={dept.id} value={dept.id}>{dept.name}</option>
1204
- ))}
1205
- </select>
1206
-
1207
- // WRONG: Raw GUID displayed to user
1208
- <span>{entity.departmentId}</span>
1209
-
1210
- // WRONG: Select with hardcoded options for FK
1211
- <select onChange={(e) => handleChange('departmentId', e.target.value)}>
1212
- <option value="guid-1">Department A</option>
1213
- </select>
1214
- ```
1215
-
1216
- **CORRECT — ONLY this pattern:**
1217
- ```tsx
1218
- <EntityLookup
1219
- apiEndpoint="/api/human-resources/departments"
1220
- value={formData.departmentId}
1221
- onChange={(id) => handleChange('departmentId', id)}
1222
- label={t('module:form.department', 'Department')}
1223
- mapOption={(dept) => ({ id: dept.id, label: dept.name, sublabel: dept.code })}
1224
- required
1225
- />
1226
- ```
1227
-
1228
- ### I18n Keys for EntityLookup
1229
-
1230
- Add these keys to the module's translation files:
1231
-
1232
- ```json
1233
- {
1234
- "form": {
1235
- "employee": "Employee",
1236
- "employeePlaceholder": "Search for an employee...",
1237
- "department": "Department",
1238
- "departmentPlaceholder": "Search for a department..."
1239
- }
1240
- }
1241
- ```
1242
-
1243
- ---
1244
-
1245
- ## 7. Documentation Panel Integration (DocToggleButton)
1246
-
1247
- > **EVERY list/detail page MUST include a `DocToggleButton` in its header.**
1248
- > This button opens the right-side documentation panel showing the module's user documentation.
1249
-
1250
- ### Component Import
1251
-
1252
- ```tsx
1253
- import { DocToggleButton } from '@/components/docs/DocToggleButton';
1254
- ```
1255
-
1256
- ### Placement — Always in the page header actions area (top right)
1257
-
1258
- ```tsx
1259
- {/* Header with DocToggleButton */}
1260
- <div className="flex items-center justify-between">
1261
- <h1 className="text-2xl font-bold text-[var(--text-primary)]">
1262
- {t('{module}:title', 'Module Title')}
1263
- </h1>
1264
- <div className="flex items-center gap-2">
1265
- <DocToggleButton />
1266
- <button onClick={() => navigate('create')} className="...">
1267
- {t('{module}:actions.create', 'Create')}
1268
- </button>
1269
- </div>
1270
- </div>
1271
- ```
1272
-
1273
- ### How it Works
1274
-
1275
- 1. `DocToggleButton` uses `useDocPanel()` context (provided by the Layout)
1276
- 2. On click → opens the `DocPanel` on the right side of the screen
1277
- 3. The panel loads the module's documentation via iframe (`?embedded=true`)
1278
- 4. Route → doc mapping is in `DocPanelContext.tsx` — maps current pathname to doc URL
1279
- 5. Panel is resizable (20-60% width), size persists in localStorage
1280
-
1281
- ### Documentation Generation
1282
-
1283
- After frontend pages are created, invoke the `/documentation` skill to generate:
1284
-
1285
- | File | Content |
1286
- |------|---------|
1287
- | `src/pages/docs/business/{app}/{module}/doc-data.ts` | Data-driven documentation (~50-80 lines) |
1288
- | `src/pages/docs/business/{app}/{module}/index.tsx` | Page wrapper (~10 lines) using `DocRenderer` |
1289
- | `src/i18n/locales/fr/docs-{app}-{module}.json` | French doc translations (source language) |
1290
-
1291
- The `DocRenderer` shared component renders all 8 documentation sections (overview, use cases, benefits, features, steps, FAQ, business rules, permissions, API endpoints) from the `doc-data.ts` file.
1292
-
1293
- ### Custom Doc URL (optional)
1294
-
1295
- If the automatic route mapping doesn't work for your module, pass a custom URL:
1296
-
1297
- ```tsx
1298
- <DocToggleButton customDocUrl="/docs/human-resources/employees" />
1299
- ```
1300
-
1301
- ### Rules
1302
-
1303
- - **EVERY** list page MUST include `DocToggleButton` in its header actions
1304
- - **EVERY** detail page MUST include `DocToggleButton` in its header actions
1305
- - Create/Edit form pages do NOT need DocToggleButton (users don't read docs while filling forms)
1306
- - DocToggleButton is imported from `@/components/docs/DocToggleButton` (shared component)
1307
- - The Layout already provides `DocPanelProvider` — no additional wrapping needed
1308
- - Documentation content is generated by the `/documentation` skill AFTER frontend pages exist
1309
-
1310
- ---
1311
-
1312
- ## 7b. Checklist for /apex Frontend Execution
1313
-
1314
- Before marking frontend tasks as complete, verify:
1315
-
1316
- - [ ] All page imports use `React.lazy()` with named export wrapping
1317
- - [ ] `<Suspense fallback={<PageLoader />}>` wraps all lazy components in routes
1318
- - [ ] Translation files exist for **all 4 languages** (fr, en, it, de) in `src/i18n/locales/`
1319
- - [ ] All `t()` calls include namespace prefix AND fallback value
1320
- - [ ] No hardcoded strings in JSX — all text goes through `t()`
1321
- - [ ] CSS uses variables only — no hardcoded Tailwind colors (BLOCKING POST-CHECK 9)
1322
- - [ ] Pages follow loading → error → content pattern
1323
- - [ ] Pages use `src/pages/{App}/{Module}/` hierarchy
1324
- - [ ] API calls use generated hooks or `apiClient` (never raw axios)
1325
- - [ ] Components use DataTable/EntityCard (never raw HTML `<table>`)
1326
- - [ ] **FK fields use `EntityLookup` — ZERO plain text inputs for Guid FK fields**
1327
- - [ ] **All FK fields have `mapOption` showing display name, not GUID**
1328
- - [ ] **Backend APIs support `?search=` query parameter for EntityLookup**
1329
- - [ ] **Create/Edit forms are full pages with own routes — ZERO modals/popups/drawers**
1330
- - [ ] `EntityCreatePage.tsx` exists with route `/{module}/create`
1331
- - [ ] `EntityEditPage.tsx` exists with route `/{module}/:id/edit`
1332
- - [ ] No `<Modal>`, `<Dialog>`, `<Drawer>` imports in form-related pages
1333
- - [ ] Form pages include back button with `navigate(-1)`
1334
- - [ ] Form pages are covered by frontend tests (see section 8)
1335
- - [ ] **`DocToggleButton` present in header of every list/detail page (see section 7)**
1336
- - [ ] **`/documentation` skill invoked to generate module doc-data.ts**
1337
-
1338
- ---
1339
-
1340
- ## 7c. Cross-Tenant Entity UI Patterns
1341
-
1342
- > **For optional and scoped tenant entities, the frontend MUST provide UI controls to set the scope/visibility.**
1343
-
1344
- ### Scope Types
1345
-
1346
- | Type | Behavior | Use case |
1347
- |------|----------|----------|
1348
- | **Optional** | Entity can be tenant-specific OR shared (binary choice) | Data that can belong to one org or all orgs |
1349
- | **Scoped** | Entity has explicit scope enum: Tenant / Shared / Platform | Data with multiple visibility levels |
1350
-
1351
- ### Scope Selector in Create Forms (Optional Entities)
1352
-
1353
- For `optional` tenant entities, add a toggle in the create form allowing the user to decide:
1354
-
1355
- ```tsx
1356
- import { useState } from 'react';
1357
- import { useTranslation } from 'react-i18next';
1358
-
1359
- export function EntityCreatePage() {
1360
- const { t } = useTranslation(['{module}']);
1361
- const [formData, setFormData] = useState({
1362
- name: '',
1363
- isShared: false, // User decision: tenant-specific (false) or shared (true)
1364
- });
1365
-
1366
- const handleScopeChange = (value: string) => {
1367
- setFormData({ ...formData, isShared: value === 'shared' });
1368
- };
1369
-
1370
- return (
1371
- <div className="space-y-6">
1372
- {/* ... form header ... */}
1373
-
1374
- <form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
1375
- {/* Name field */}
1376
- <div>
1377
- <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
1378
- {t('{module}:form.name', 'Name')}
1379
- </label>
1380
- <input
1381
- type="text"
1382
- value={formData.name}
1383
- onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
1384
- 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)]"
1385
- required
1386
- />
1387
- </div>
1388
-
1389
- {/* Scope selector — binary toggle for optional entities */}
1390
- <div className="space-y-2">
1391
- <label className="block text-sm font-medium text-[var(--text-primary)]">
1392
- {t('common:scope', 'Scope')}
1393
- </label>
1394
- <select
1395
- value={formData.isShared ? 'shared' : 'tenant'}
1396
- onChange={(e) => handleScopeChange(e.target.value)}
1397
- className="w-full px-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)] text-[var(--text-primary)]"
1398
- >
1399
- <option value="tenant">
1400
- {t('common:scope.tenant', 'My Organization')}
1401
- </option>
1402
- <option value="shared">
1403
- {t('common:scope.shared', 'Shared (All Organizations)')}
1404
- </option>
1405
- </select>
1406
- <p className="text-xs text-[var(--text-secondary)]">
1407
- {formData.isShared
1408
- ? t('common:scope.shared.hint', 'This data will be accessible to all organizations')
1409
- : t('common:scope.tenant.hint', 'This data will only be visible to your organization')}
1410
- </p>
1411
- </div>
1412
-
1413
- {/* Actions */}
1414
- <div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
1415
- <button type="button" onClick={() => navigate(-1)} className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]">
1416
- {t('common:actions.cancel', 'Cancel')}
1417
- </button>
1418
- <button type="submit" disabled={submitting} className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)] disabled:opacity-50">
1419
- {submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
1420
- </button>
1421
- </div>
1422
- </form>
1423
- </div>
1424
- );
1425
- }
1426
- ```
1427
-
1428
- ### Scope Selector in Create Forms (Scoped Entities)
1429
-
1430
- For `scoped` entities with explicit enum values (Tenant, Shared, Platform), use a dropdown with all scope options:
1431
-
1432
- ```tsx
1433
- export function EntityCreatePage() {
1434
- const { t } = useTranslation(['{module}']);
1435
- const [formData, setFormData] = useState({
1436
- name: '',
1437
- scope: 'Tenant', // Enum: 'Tenant' | 'Shared' | 'Platform'
1438
- });
1439
-
1440
- return (
1441
- <form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
1442
- {/* Name field */}
1443
- <div>
1444
- <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
1445
- {t('{module}:form.name', 'Name')}
1446
- </label>
1447
- <input
1448
- type="text"
1449
- value={formData.name}
1450
- onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
1451
- 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)]"
1452
- required
1453
- />
1454
- </div>
1455
-
1456
- {/* Scope selector — enum values for scoped entities */}
1457
- <div>
1458
- <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
1459
- {t('common:scope', 'Scope')}
1460
- </label>
1461
- <select
1462
- value={formData.scope}
1463
- onChange={(e) => setFormData(prev => ({ ...prev, scope: e.target.value }))}
1464
- className="w-full px-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)] text-[var(--text-primary)]"
1465
- required
1466
- >
1467
- <option value="Tenant">{t('common:scope.tenant', 'My Organization')}</option>
1468
- <option value="Shared">{t('common:scope.shared', 'Shared')}</option>
1469
- <option value="Platform">{t('common:scope.platform', 'Platform (Admin Only)')}</option>
1470
- </select>
1471
- <p className="text-xs text-[var(--text-secondary)] mt-1">
1472
- {t('common:scope.help', 'Select the visibility level for this data')}
1473
- </p>
1474
- </div>
1475
-
1476
- {/* Actions */}
1477
- <div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
1478
- <button type="button" onClick={() => navigate(-1)} className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]">
1479
- {t('common:actions.cancel', 'Cancel')}
1480
- </button>
1481
- <button type="submit" disabled={submitting} className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)] disabled:opacity-50">
1482
- {submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
1483
- </button>
1484
- </div>
1485
- </form>
1486
- );
1487
- }
1488
- ```
1489
-
1490
- ### Scope Indicator in List Views
1491
-
1492
- Display a visual indicator/badge on each row showing the entity scope:
1493
-
1494
- ```tsx
1495
- import { useTranslation } from 'react-i18next';
1496
-
1497
- // ScopeBadge component for reuse
1498
- interface ScopeBadgeProps {
1499
- tenantId?: string | null; // For optional entities: null = shared, value = tenant-specific
1500
- scope?: string; // For scoped entities: 'Tenant' | 'Shared' | 'Platform'
1501
- }
1502
-
1503
- export function ScopeBadge({ tenantId, scope }: ScopeBadgeProps) {
1504
- const { t } = useTranslation(['common']);
1505
-
1506
- // Optional entity scope
1507
- if (tenantId !== undefined) {
1508
- const isTenant = Boolean(tenantId);
1509
- return (
1510
- <span
1511
- className={`px-2 py-1 rounded-full text-xs font-semibold ${
1512
- isTenant
1513
- ? 'bg-[var(--bg-accent-light)] text-[var(--color-accent-600)]'
1514
- : 'bg-[var(--bg-secondary)] text-[var(--text-secondary)]'
1515
- }`}
1516
- >
1517
- {isTenant
1518
- ? t('common:scope.tenant', 'Tenant')
1519
- : t('common:scope.shared', 'Shared')}
1520
- </span>
1521
- );
1522
- }
1523
-
1524
- // Scoped entity scope
1525
- if (scope) {
1526
- const scopeStyles: Record<string, { bg: string; text: string }> = {
1527
- Tenant: {
1528
- bg: 'bg-[var(--bg-accent-light)]',
1529
- text: 'text-[var(--color-accent-600)]',
1530
- },
1531
- Shared: {
1532
- bg: 'bg-[var(--bg-secondary)]',
1533
- text: 'text-[var(--text-secondary)]',
1534
- },
1535
- Platform: {
1536
- bg: 'bg-[var(--bg-warning-light)]',
1537
- text: 'text-[var(--color-warning-600)]',
1538
- },
1539
- };
1540
-
1541
- const style = scopeStyles[scope] || scopeStyles.Tenant;
1542
- const scopeLabel = {
1543
- Tenant: t('common:scope.tenant', 'Organization'),
1544
- Shared: t('common:scope.shared', 'Shared'),
1545
- Platform: t('common:scope.platform', 'Platform'),
1546
- }[scope] || scope;
1547
-
1548
- return (
1549
- <span className={`px-2 py-1 rounded-full text-xs font-semibold ${style.bg} ${style.text}`}>
1550
- {scopeLabel}
1551
- </span>
1552
- );
1553
- }
1554
-
1555
- return null;
1556
- }
1557
- ```
1558
-
1559
- ### Using ScopeBadge in DataTable Columns
1560
-
1561
- ```tsx
1562
- // In the list page, add a scope column
1563
- const columns = [
1564
- { key: 'name', label: t('{module}:columns.name', 'Name') },
1565
- { key: 'code', label: t('{module}:columns.code', 'Code') },
1566
- {
1567
- key: 'scope',
1568
- label: t('common:scope', 'Scope'),
1569
- render: (row) => (
1570
- // For optional entities: show based on tenantId
1571
- <ScopeBadge tenantId={row.tenantId} />
1572
- // OR for scoped entities: show based on scope field
1573
- // <ScopeBadge scope={row.scope} />
1574
- ),
1575
- },
1576
- { key: 'actions', label: t('{module}:columns.actions', 'Actions') },
1577
- ];
1578
-
1579
- return (
1580
- <DataTable
1581
- columns={columns}
1582
- data={data}
1583
- searchable
1584
- pagination={{ pageSize: 10 }}
1585
- onRowClick={(row) => navigate(`${row.id}`)}
1586
- />
1587
- );
1588
- ```
1589
-
1590
- ### I18n Keys for Scope UI
1591
-
1592
- Add these keys to `src/i18n/locales/*/common.json`:
1593
-
1594
- ```json
1595
- {
1596
- "scope": "Scope",
1597
- "scope.tenant": "My Organization",
1598
- "scope.tenant.hint": "This data will only be visible to your organization",
1599
- "scope.shared": "Shared (All Organizations)",
1600
- "scope.shared.hint": "This data will be accessible to all organizations",
1601
- "scope.platform": "Platform (Admin Only)",
1602
- "scope.help": "Select the visibility level for this data"
1603
- }
1604
- ```
1605
-
1606
- And in the module-specific translation files (e.g., `employees.json`):
1607
-
1608
- ```json
1609
- {
1610
- "form": {
1611
- "scope": "Scope",
1612
- "scopeHint": "Choose who can see this data"
1613
- }
1614
- }
1615
- ```
1616
-
1617
- ### Rules
1618
-
1619
- - **ALWAYS** provide scope controls in create forms for optional/scoped entities
1620
- - **ALWAYS** show scope indicator badges in list views
1621
- - **ALWAYS** use `ScopeBadge` component for consistency across modules
1622
- - **NEVER** let users create shared entities without explicit choice
1623
- - **NEVER** hide scope controls — scope is a business-critical property
1624
- - **ALWAYS** include scope-related translation keys in i18n files (all 4 languages)
1625
- - **FORBIDDEN:** Form field for scope labeled ambiguously (e.g., "Public/Private" without context)
1626
- - **FORBIDDEN:** Scope badges with hardcoded colors — always use CSS variables
1627
-
1628
- ---
1629
-
1630
- ## 8. Frontend Form Testing
1631
-
1632
- > **ALL form pages MUST have tests.** Forms are critical user interaction points and MUST be verified.
1633
-
1634
- ### Required Test Coverage per Form Page
1635
-
1636
- | Test category | What to verify | Tool |
1637
- |---------------|---------------|------|
1638
- | Rendering | Form renders with all expected fields | Vitest + React Testing Library |
1639
- | Validation | Required fields show errors on empty submit | Vitest + React Testing Library |
1640
- | Submission | Successful submit calls API and navigates back | Vitest + MSW (mock API) |
1641
- | Pre-fill (edit) | Edit form loads entity data into fields | Vitest + React Testing Library |
1642
- | Navigation | Back button calls `navigate(-1)` | Vitest + React Testing Library |
1643
- | Error handling | API error displays error message | Vitest + MSW |
1644
-
1645
- ### Test File Convention
1646
-
1647
- ```
1648
- src/pages/{App}/{Module}/
1649
- ├── EntityCreatePage.tsx
1650
- ├── EntityCreatePage.test.tsx ← MANDATORY
1651
- ├── EntityEditPage.tsx
1652
- ├── EntityEditPage.test.tsx ← MANDATORY
1653
- ├── EntityListPage.tsx
1654
- └── EntityDetailPage.tsx
1655
- ```
1656
-
1657
- ### Create Page Test Template
1658
-
1659
- ```tsx
1660
- import { render, screen, waitFor } from '@testing-library/react';
1661
- import userEvent from '@testing-library/user-event';
1662
- import { MemoryRouter } from 'react-router-dom';
1663
- import { describe, it, expect, vi } from 'vitest';
1664
- import { EntityCreatePage } from './EntityCreatePage';
1665
-
1666
- // Mock API
1667
- vi.mock('@/services/api/apiClient');
1668
- const mockNavigate = vi.fn();
1669
- vi.mock('react-router-dom', async () => ({
1670
- ...(await vi.importActual('react-router-dom')),
1671
- useNavigate: () => mockNavigate,
1672
- }));
1673
-
1674
- describe('EntityCreatePage', () => {
1675
- it('renders the create form with all fields', () => {
1676
- render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
1677
- expect(screen.getByRole('textbox', { name: /name/i })).toBeInTheDocument();
1678
- // Verify all expected form fields
1679
- });
1680
-
1681
- it('shows validation errors on empty submit', async () => {
1682
- render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
1683
- await userEvent.click(screen.getByRole('button', { name: /save|create/i }));
1684
- await waitFor(() => {
1685
- expect(screen.getByText(/required/i)).toBeInTheDocument();
1686
- });
1687
- });
1688
-
1689
- it('submits form and navigates back on success', async () => {
1690
- render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
1691
- await userEvent.type(screen.getByRole('textbox', { name: /name/i }), 'Test');
1692
- await userEvent.click(screen.getByRole('button', { name: /save|create/i }));
1693
- await waitFor(() => {
1694
- expect(mockNavigate).toHaveBeenCalledWith(-1);
1695
- });
1696
- });
1697
-
1698
- it('navigates back on cancel/back button', async () => {
1699
- render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
1700
- await userEvent.click(screen.getByRole('button', { name: /back|cancel/i }));
1701
- expect(mockNavigate).toHaveBeenCalledWith(-1);
1702
- });
1703
- });
1704
- ```
1705
-
1706
- ### Edit Page Test Template
1707
-
1708
- ```tsx
1709
- describe('EntityEditPage', () => {
1710
- it('loads entity data and pre-fills the form', async () => {
1711
- render(<MemoryRouter initialEntries={['/entities/123/edit']}><EntityEditPage /></MemoryRouter>);
1712
- await waitFor(() => {
1713
- expect(screen.getByDisplayValue('Existing Name')).toBeInTheDocument();
1714
- });
1715
- });
1716
-
1717
- it('submits updated data and navigates back', async () => {
1718
- render(<MemoryRouter initialEntries={['/entities/123/edit']}><EntityEditPage /></MemoryRouter>);
1719
- await waitFor(() => screen.getByDisplayValue('Existing Name'));
1720
- await userEvent.clear(screen.getByRole('textbox', { name: /name/i }));
1721
- await userEvent.type(screen.getByRole('textbox', { name: /name/i }), 'Updated');
1722
- await userEvent.click(screen.getByRole('button', { name: /save/i }));
1723
- await waitFor(() => {
1724
- expect(mockNavigate).toHaveBeenCalledWith(-1);
1725
- });
1726
- });
1727
-
1728
- it('displays error when API call fails', async () => {
1729
- // Mock API to reject
1730
- render(<MemoryRouter initialEntries={['/entities/123/edit']}><EntityEditPage /></MemoryRouter>);
1731
- // ... trigger submit with mocked failure
1732
- await waitFor(() => {
1733
- expect(screen.getByText(/failed/i)).toBeInTheDocument();
1734
- });
1735
- });
1736
- });
1737
- ```
1738
-
1739
- ### Rules
1740
-
1741
- - **EVERY** `EntityCreatePage.tsx` MUST have a companion `EntityCreatePage.test.tsx`
1742
- - **EVERY** `EntityEditPage.tsx` MUST have a companion `EntityEditPage.test.tsx`
1743
- - Tests MUST cover: rendering, validation, submit success, submit error, navigation
1744
- - Use `@testing-library/react` + `@testing-library/user-event` (NEVER enzyme)
1745
- - Mock API with `vi.mock()` or MSW — NEVER make real API calls in tests
1746
- - Test files live next to their component (co-located, NOT in a separate `__tests__/` folder)
1747
-
1748
- ---
1749
-
1750
- ## 9. Frontend Compliance Gates (5 Mandatory Checks)
1751
-
1752
- > **Run these checks before any frontend commit.** All 5 gates MUST pass.
1753
- > Previously in separate files `execution-frontend-gates.md` and `execution-frontend-patterns.md` — now consolidated here.
1754
-
1755
- ### Gate 1: CSS Variables (Theme System)
1756
-
1757
- ```bash
1758
- ALL_PAGES=$(find src/pages/ src/components/ -name "*.tsx" 2>/dev/null | grep -v node_modules | grep -v "\.test\.")
1759
- if [ -n "$ALL_PAGES" ]; then
1760
- HARDCODED=$(grep -Pn '(bg|text|border)-(?!\[)(red|blue|green|gray|white|black|slate|zinc|neutral|stone)-' $ALL_PAGES 2>/dev/null)
1761
- if [ -n "$HARDCODED" ]; then
1762
- echo "FAIL: Hardcoded Tailwind colors found — must use CSS variables (see section 4)"
1763
- echo "$HARDCODED"
1764
- else
1765
- echo "PASS: CSS variables"
1766
- fi
1767
- fi
1768
- ```
1769
-
1770
- **Fix mapping:** See section 4 (CSS Variables) for the complete variable reference table.
1771
-
1772
- ### Gate 2: Forms as Pages (ZERO Modals/Drawers)
1773
-
1774
- ```bash
1775
- PAGE_FILES=$(find src/pages/ -name "*.tsx" 2>/dev/null)
1776
- if [ -n "$PAGE_FILES" ]; then
1777
- FAIL=false
1778
- MODAL_IMPORTS=$(grep -Pn "import.*(?:Modal|Dialog|Drawer|Popup|Sheet|SlideOver|Overlay)" $PAGE_FILES 2>/dev/null)
1779
- if [ -n "$MODAL_IMPORTS" ]; then
1780
- echo "FAIL: Modal/Dialog/Drawer imports — forms MUST be full pages (see section 3b)"
1781
- echo "$MODAL_IMPORTS"
1782
- FAIL=true
1783
- fi
1784
- MODAL_STATE=$(grep -Pn "useState.*(?:isOpen|showModal|showDialog|showCreate|showEdit|showForm|isCreating|isEditing|showDrawer|showPanel|showSlideOver|selectedEntity|editingEntity)" $PAGE_FILES 2>/dev/null)
1785
- if [ -n "$MODAL_STATE" ]; then
1786
- echo "FAIL: Inline form state detected — forms MUST be separate pages (see section 3b)"
1787
- echo "$MODAL_STATE"
1788
- FAIL=true
1789
- fi
1790
- if [ "$FAIL" = false ]; then echo "PASS: No modals/drawers"; fi
1791
- fi
1792
- ```
1793
-
1794
- ### Gate 3: I18n File Structure
1795
-
1796
- ```bash
1797
- if [ ! -d "src/i18n/locales" ]; then
1798
- echo "FAIL: Missing src/i18n/locales/ directory"
1799
- else
1800
- for LANG in fr en it de; do
1801
- JSON_FILES=$(find "src/i18n/locales/$LANG" -name "*.json" 2>/dev/null | wc -l)
1802
- if [ "$JSON_FILES" -eq 0 ]; then
1803
- echo "FAIL: No JSON files in src/i18n/locales/$LANG/"
1804
- else
1805
- echo "PASS: $LANG ($JSON_FILES files)"
1806
- fi
1807
- done
1808
- fi
1809
- ```
1810
-
1811
- ### Gate 4: Lazy Loading
1812
-
1813
- ```bash
1814
- APP_TSX=$(find src/ -name "App.tsx" -not -path "*/node_modules/*" 2>/dev/null | head -1)
1815
- ROUTE_FILES=$(find src/routes/ -name "*.tsx" -o -name "*.ts" 2>/dev/null)
1816
- if [ -n "$APP_TSX" ]; then
1817
- STATIC_IMPORTS=$(grep -Pn "^import .+ from '@/pages/" "$APP_TSX" $ROUTE_FILES 2>/dev/null)
1818
- if [ -n "$STATIC_IMPORTS" ]; then
1819
- echo "FAIL: Static page imports — MUST use React.lazy() (see section 1)"
1820
- echo "$STATIC_IMPORTS"
1821
- else
1822
- echo "PASS: Lazy loading"
1823
- fi
1824
- fi
1825
- ```
1826
-
1827
- ### Gate 5: useTranslation in Pages
1828
-
1829
- ```bash
1830
- PAGE_FILES=$(find src/pages/ -name "*.tsx" 2>/dev/null | grep -v "\.test\." | grep -v node_modules)
1831
- if [ -n "$PAGE_FILES" ]; then
1832
- TOTAL=$(echo "$PAGE_FILES" | wc -l)
1833
- WITH_I18N=$(grep -l "useTranslation" $PAGE_FILES 2>/dev/null | wc -l)
1834
- if [ "$WITH_I18N" -eq 0 ]; then
1835
- echo "FAIL: No pages use useTranslation — all text must be translated (see section 2)"
1836
- else
1837
- echo "PASS: $WITH_I18N/$TOTAL pages use useTranslation"
1838
- fi
1839
- fi
1840
- ```
1841
-
1842
- > **ALL 5 gates MUST pass before frontend commit.** When delegating to `/ui-components` skill, include explicit instructions: CSS variables only, forms as full pages, i18n with namespace + fallback.
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/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['hr.employees'].permissions}>
36
+ <EmployeesPage />
37
+ </PermissionGuard>
38
+ </Suspense>
39
+ )
40
+ ```
41
+
42
+ ### Rules
43
+
44
+ - Do not static-import page components in route files
45
+ - Use `<Suspense fallback={<PageLoader />}>` around lazy components
46
+ - Use the `.then(m => ({ default: m.ComponentName }))` pattern for named exports
47
+ - The unified AppLayout component is ALSO lazy-loaded
48
+
49
+ **Incorrect patterns:**
50
+ ```tsx
51
+ // WRONG: static import in route file
52
+ import { EmployeesPage } from '@/pages/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 Required
62
+
63
+ In the client `App.tsx` (where application routes 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/HumanResources/Clients/ClientsListPage')
69
+ .then(m => ({ default: m.ClientsListPage }))
70
+ );
71
+ ```
72
+
73
+ **Do not use — Static imports in client App.tsx:**
74
+ ```tsx
75
+ // WRONG: Static import kills code splitting
76
+ import { ClientsListPage } from '@/pages/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 provide fallback value
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
+ ### Namespace Registration
191
+
192
+ After creating i18n JSON files, register each namespace in the i18n config.
193
+ Root cause (test-apex-007): JSON files existed but namespaces were not registered → `useTranslation(['module'])` returned empty strings.
194
+
195
+ In the i18n config file (`src/i18n/config.ts` or `src/i18n/index.ts`), add each new namespace:
196
+
197
+ ```typescript
198
+ // Example: registering new module namespaces
199
+ import employees from './locales/fr/employees.json';
200
+ import projects from './locales/fr/projects.json';
201
+ import clients from './locales/fr/clients.json';
202
+
203
+ // In resources configuration:
204
+ resources: {
205
+ fr: { employees, projects, clients, common, navigation },
206
+ en: { employees: employeesEn, projects: projectsEn, clients: clientsEn, ... },
207
+ // ... it, de
208
+ }
209
+
210
+ // OR with ns array:
211
+ ns: ['common', 'navigation', 'employees', 'projects', 'clients'],
212
+ ```
213
+
214
+ POST-CHECK C39 validates this. Unregistered namespaces must be registered.
215
+
216
+ ### Rules
217
+
218
+ - Provide a fallback value as 2nd argument to `t()`
219
+ - Use namespace prefix: `t('namespace:key')`
220
+ - Generate 4 language files (fr, en, it, de) with identical key structures
221
+ - Register new namespaces in i18n config file after creating JSON files
222
+ - Do not hardcode user-facing strings in JSX
223
+ - Do not use `t('key')` without namespace prefix
224
+
225
+ **Incorrect patterns:**
226
+ ```tsx
227
+ // WRONG: no fallback
228
+ t('employees:title')
229
+
230
+ // WRONG: no namespace
231
+ t('title')
232
+
233
+ // WRONG: hardcoded text
234
+ <h1>Employees</h1>
235
+
236
+ // WRONG: only 2 languages generated
237
+ // Must have fr, en, it, de
238
+ ```
239
+
240
+ ---
241
+
242
+ ## 3. Page Structure Pattern
243
+
244
+ > **ALL pages MUST follow this structure.** Extracted from SmartStack.app reference implementation.
245
+
246
+ ### Standard List Page Template
247
+
248
+ ```tsx
249
+ import { useState, useCallback, useEffect } from 'react';
250
+ import { useTranslation } from 'react-i18next';
251
+ import { useNavigate, useParams } from 'react-router-dom';
252
+ import { Loader2 } from 'lucide-react';
253
+ import { DocToggleButton } from '@/components/docs/DocToggleButton';
254
+ import { DataTable } from '@/components/ui/DataTable';
255
+
256
+ // API hook (generated by scaffold_api_client)
257
+ import { useEntityList } from '@/hooks/useEntity';
258
+
259
+ export function EntityListPage() {
260
+ // 1. HOOKS — always at the top
261
+ const { t } = useTranslation(['{module}']);
262
+ const navigate = useNavigate();
263
+
264
+ // 2. STATE
265
+ const [loading, setLoading] = useState(true);
266
+ const [error, setError] = useState<string | null>(null);
267
+ const [data, setData] = useState<Entity[]>([]);
268
+
269
+ // 3. DATA LOADING (useCallback + useEffect)
270
+ const loadData = useCallback(async () => {
271
+ try {
272
+ setLoading(true);
273
+ setError(null);
274
+ const result = await entityApi.getAll();
275
+ setData(result.items);
276
+ } catch (err: any) {
277
+ setError(err.message || t('{module}:errors.loadFailed', 'Failed to load data'));
278
+ } finally {
279
+ setLoading(false);
280
+ }
281
+ }, [t]);
282
+
283
+ useEffect(() => {
284
+ loadData();
285
+ }, [loadData]);
286
+
287
+ // 4. LOADING STATE
288
+ if (loading) {
289
+ return (
290
+ <div className="flex items-center justify-center min-h-[400px]">
291
+ <Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
292
+ </div>
293
+ );
294
+ }
295
+
296
+ // 5. ERROR STATE
297
+ if (error) {
298
+ return (
299
+ <div className="flex items-center justify-center min-h-[400px]">
300
+ <div className="text-center">
301
+ <p className="text-[var(--text-secondary)]">{error}</p>
302
+ <button
303
+ onClick={loadData}
304
+ className="mt-4 px-4 py-2 bg-[var(--color-accent-500)] text-white rounded"
305
+ >
306
+ {t('common:actions.retry', 'Retry')}
307
+ </button>
308
+ </div>
309
+ </div>
310
+ );
311
+ }
312
+
313
+ // 6. CONTENT — create button navigates to /create route
314
+ return (
315
+ <div className="space-y-6">
316
+ {/* Header with DocToggleButton */}
317
+ <div className="flex items-center justify-between">
318
+ <h1 className="text-2xl font-bold text-[var(--text-primary)]">
319
+ {t('{module}:title', 'Module Title')}
320
+ </h1>
321
+ <div className="flex items-center gap-2">
322
+ <DocToggleButton />
323
+ <button
324
+ onClick={() => navigate('create')}
325
+ className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded"
326
+ >
327
+ {t('{module}:actions.create', 'Create')}
328
+ </button>
329
+ </div>
330
+ </div>
331
+
332
+ {/* Content: DataTable with row click → detail */}
333
+ {data.length === 0 ? (
334
+ <div className="text-center py-12 text-[var(--text-secondary)]">
335
+ {t('{module}:empty', 'No items found.')}
336
+ </div>
337
+ ) : (
338
+ <DataTable
339
+ data={data}
340
+ columns={[
341
+ { key: 'name', label: t('{module}:columns.name', 'Name'), sortable: true },
342
+ { key: 'code', label: t('{module}:columns.code', 'Code'), sortable: true },
343
+ { key: 'status', label: t('{module}:columns.status', 'Status'),
344
+ render: (item) => (
345
+ <span className={`px-2 py-0.5 rounded text-xs ${
346
+ item.isActive
347
+ ? 'bg-[var(--success-bg)] text-[var(--success-text)]'
348
+ : 'bg-[var(--error-bg)] text-[var(--error-text)]'
349
+ }`}>
350
+ {item.isActive ? t('common:status.active', 'Active') : t('common:status.inactive', 'Inactive')}
351
+ </span>
352
+ )
353
+ },
354
+ ]}
355
+ searchable
356
+ pagination={{ pageSize: 10 }}
357
+ onRowClick={(item) => navigate(`${item.id}`)}
358
+ />
359
+ )}
360
+ </div>
361
+ );
362
+ }
363
+ ```
364
+
365
+ ### Detail Page Pattern
366
+
367
+ ```tsx
368
+ export function EntityDetailPage() {
369
+ const { entityId } = useParams<{ entityId: string }>();
370
+ const { t } = useTranslation(['{module}']);
371
+ const navigate = useNavigate();
372
+
373
+ const [entity, setEntity] = useState<Entity | null>(null);
374
+ const [loading, setLoading] = useState(true);
375
+ const [activeTab, setActiveTab] = useState('info');
376
+
377
+ // Lazy tab loading — load data only when tab is first visited
378
+ const visitedTabsRef = useRef<Set<string>>(new Set(['info']));
379
+
380
+ useEffect(() => {
381
+ if (!visitedTabsRef.current.has(activeTab)) {
382
+ visitedTabsRef.current.add(activeTab);
383
+ // Load tab-specific data here (e.g., fetch leaves for this employee)
384
+ }
385
+ }, [activeTab]);
386
+
387
+ // Edit button navigates to /:id/edit route (not modal)
388
+ const handleEdit = () => navigate(`edit`);
389
+
390
+ // ... loading/error/content pattern
391
+ }
392
+ ```
393
+
394
+ ### Tab Behavior Rules
395
+
396
+ Tabs on detail pages switch content locally, not by navigating to other pages.
397
+ Each tab renders its content inline within the same page component.
398
+ Sub-resource data (e.g., an employee's leaves) loads via API call filtered by the parent entity ID.
399
+
400
+ **Tab state management:**
401
+ - Tabs use `useState<TabKey>('info')` for the active tab — local React state only
402
+ - Tab click handler: `onClick={() => setActiveTab(tabKey)}` — do not use `navigate()`
403
+ - Tab content: conditional rendering `{activeTab === 'tabKey' && <TabContent />}`
404
+ - Lazy loading: `visitedTabsRef` tracks which tabs have been visited to avoid redundant API calls
405
+
406
+ **Tab content for sub-resources:**
407
+ ```tsx
408
+ // CORRECT — sub-resource data loaded INLINE within the tab
409
+ {activeTab === 'leaves' && (
410
+ <div>
411
+ <LeaveRequestsTable employeeId={entity.id} />
412
+ {/* Optional "View all" link INSIDE the tab content area */}
413
+ <Link to={`../leaves?employee=${entity.id}`}>
414
+ {t('employees:tabs.viewAllLeaves', 'View all leave requests')}
415
+ </Link>
416
+ </div>
417
+ )}
418
+ ```
419
+
420
+ **Incorrect tab patterns:**
421
+ ```tsx
422
+ // Incorrect — tab click handler navigates to another page
423
+ const handleTabClick = (tab: TabKey) => {
424
+ setActiveTab(tab);
425
+ if (tab === 'leaves') navigate(`../leaves?employee=${id}`); // ← breaks tab UX
426
+ };
427
+
428
+ // Incorrect — tab content is empty because navigation already left the page
429
+ {activeTab === 'info' && <div>...</div>}
430
+ // Leaves tab: nothing renders here, user is already on another page
431
+ ```
432
+
433
+ **Why this matters:**
434
+ - Navigating away loses the detail page context (entity data, scroll position, other tab state)
435
+ - Users expect tabs to switch content in-place, not redirect to a different page
436
+ - The browser back button should go to the list page, not toggle between tabs
437
+
438
+ **POST-CHECK C37 enforces this rule.**
439
+
440
+ ---
441
+
442
+ ## 3b. Form Pages Pattern (Create / Edit)
443
+
444
+ All forms must be full pages with their own URL route. Do not use modals, dialogs, drawers, or popups for create/edit forms.
445
+
446
+ ### Route Convention
447
+
448
+ Route paths must use **kebab-case** matching the navigation seed data (which uses `ToKebabCase()`).
449
+ - Single word: `employees` (no change needed)
450
+ - Multi-word: `human-resources`, `time-management` (kebab-case with hyphens)
451
+ - Incorrect: `humanresources`, `timemanagement` (concatenated words without hyphens)
452
+
453
+ | Action | Route pattern | Page component | File location |
454
+ |--------|--------------|----------------|---------------|
455
+ | Create | `/{module}/create` | `EntityCreatePage` | `src/pages/{App}/{Module}/EntityCreatePage.tsx` |
456
+ | Edit | `/{module}/:id/edit` | `EntityEditPage` | `src/pages/{App}/{Module}/EntityEditPage.tsx` |
457
+
458
+ ### Create Page Template
459
+
460
+ ```tsx
461
+ import { useState } from 'react';
462
+ import { useTranslation } from 'react-i18next';
463
+ import { useNavigate } from 'react-router-dom';
464
+ import { ArrowLeft } from 'lucide-react';
465
+ // For FK Guid fields: import { EntityLookup } from '@/components/ui/EntityLookup';
466
+
467
+ export function EntityCreatePage() {
468
+ const { t } = useTranslation(['{module}']);
469
+ const navigate = useNavigate();
470
+ const [submitting, setSubmitting] = useState(false);
471
+ const [error, setError] = useState<string | null>(null);
472
+ const [formData, setFormData] = useState<CreateEntityDto>({
473
+ name: '',
474
+ // departmentId: '', ← FK Guid field (use EntityLookup below)
475
+ });
476
+
477
+ const handleSubmit = async (e: React.FormEvent) => {
478
+ e.preventDefault();
479
+ try {
480
+ setSubmitting(true);
481
+ setError(null);
482
+ await entityApi.create(formData);
483
+ navigate(-1); // Back to list
484
+ } catch (err: any) {
485
+ setError(err.message || t('{module}:errors.createFailed', 'Creation failed'));
486
+ } finally {
487
+ setSubmitting(false);
488
+ }
489
+ };
490
+
491
+ return (
492
+ <div className="space-y-6">
493
+ {/* Back button */}
494
+ <button
495
+ onClick={() => navigate(-1)}
496
+ className="flex items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
497
+ >
498
+ <ArrowLeft className="w-4 h-4" />
499
+ {t('common:actions.back', 'Back')}
500
+ </button>
501
+
502
+ {/* Page title */}
503
+ <h1 className="text-2xl font-bold text-[var(--text-primary)]">
504
+ {t('{module}:actions.create', 'Create {Entity}')}
505
+ </h1>
506
+
507
+ {/* Error state */}
508
+ {error && (
509
+ <div className="p-4 bg-[var(--error-bg)] border border-[var(--error-border)] rounded-[var(--radius-card)]">
510
+ <span className="text-[var(--error-text)]">{error}</span>
511
+ </div>
512
+ )}
513
+
514
+ {/* Form page — not modal */}
515
+ <form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
516
+ {/* Text field */}
517
+ <div>
518
+ <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
519
+ {t('{module}:form.name', 'Name')}
520
+ </label>
521
+ <input
522
+ type="text"
523
+ value={formData.name}
524
+ onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
525
+ 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)]"
526
+ required
527
+ />
528
+ </div>
529
+
530
+ {/* FK Guid field — use EntityLookup, not <select> or <input> */}
531
+ {/* <div>
532
+ <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
533
+ {t('{module}:form.department', 'Department')}
534
+ </label>
535
+ <EntityLookup
536
+ apiEndpoint="/api/{app}/{module}/departments"
537
+ value={formData.departmentId}
538
+ onChange={(id) => setFormData(prev => ({ ...prev, departmentId: id }))}
539
+ mapOption={(dept) => ({ label: dept.name, value: dept.id })}
540
+ placeholder={t('{module}:form.selectDepartment', 'Select a department...')}
541
+ />
542
+ </div> */}
543
+
544
+ {/* Actions */}
545
+ <div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
546
+ <button
547
+ type="button"
548
+ onClick={() => navigate(-1)}
549
+ className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]"
550
+ >
551
+ {t('common:actions.cancel', 'Cancel')}
552
+ </button>
553
+ <button
554
+ type="submit"
555
+ disabled={submitting}
556
+ 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"
557
+ >
558
+ {submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
559
+ </button>
560
+ </div>
561
+ </form>
562
+ </div>
563
+ );
564
+ }
565
+ ```
566
+
567
+ ### Edit Page Template
568
+
569
+ ```tsx
570
+ import { useState, useEffect, useCallback } from 'react';
571
+ import { useTranslation } from 'react-i18next';
572
+ import { useNavigate, useParams } from 'react-router-dom';
573
+ import { Loader2, ArrowLeft } from 'lucide-react';
574
+ // For FK Guid fields: import { EntityLookup } from '@/components/ui/EntityLookup';
575
+
576
+ export function EntityEditPage() {
577
+ const { entityId } = useParams<{ entityId: string }>();
578
+ const { t } = useTranslation(['{module}']);
579
+ const navigate = useNavigate();
580
+ const [formData, setFormData] = useState<UpdateEntityDto | null>(null);
581
+ const [loading, setLoading] = useState(true);
582
+ const [submitting, setSubmitting] = useState(false);
583
+ const [error, setError] = useState<string | null>(null);
584
+
585
+ const loadEntity = useCallback(async () => {
586
+ try {
587
+ setLoading(true);
588
+ const result = await entityApi.getById(entityId!);
589
+ setFormData(result);
590
+ } catch {
591
+ navigate(-1);
592
+ } finally {
593
+ setLoading(false);
594
+ }
595
+ }, [entityId, navigate]);
596
+
597
+ useEffect(() => { loadEntity(); }, [loadEntity]);
598
+
599
+ if (loading || !formData) {
600
+ return (
601
+ <div className="flex items-center justify-center min-h-[400px]">
602
+ <Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
603
+ </div>
604
+ );
605
+ }
606
+
607
+ const handleSubmit = async (e: React.FormEvent) => {
608
+ e.preventDefault();
609
+ try {
610
+ setSubmitting(true);
611
+ setError(null);
612
+ await entityApi.update(entityId!, formData);
613
+ navigate(-1); // Back to detail or list
614
+ } catch (err: any) {
615
+ setError(err.message || t('{module}:errors.updateFailed', 'Update failed'));
616
+ } finally {
617
+ setSubmitting(false);
618
+ }
619
+ };
620
+
621
+ return (
622
+ <div className="space-y-6">
623
+ {/* Back button */}
624
+ <button
625
+ onClick={() => navigate(-1)}
626
+ className="flex items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
627
+ >
628
+ <ArrowLeft className="w-4 h-4" />
629
+ {t('common:actions.back', 'Back')}
630
+ </button>
631
+
632
+ {/* Page title */}
633
+ <h1 className="text-2xl font-bold text-[var(--text-primary)]">
634
+ {t('{module}:actions.edit', 'Edit {Entity}')}
635
+ </h1>
636
+
637
+ {/* Error state */}
638
+ {error && (
639
+ <div className="p-4 bg-[var(--error-bg)] border border-[var(--error-border)] rounded-[var(--radius-card)]">
640
+ <span className="text-[var(--error-text)]">{error}</span>
641
+ </div>
642
+ )}
643
+
644
+ {/* Form page — not modal */}
645
+ <form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
646
+ {/* Text field (pre-filled) */}
647
+ <div>
648
+ <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
649
+ {t('{module}:form.name', 'Name')}
650
+ </label>
651
+ <input
652
+ type="text"
653
+ value={formData.name}
654
+ onChange={(e) => setFormData(prev => prev ? { ...prev, name: e.target.value } : prev)}
655
+ 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)]"
656
+ required
657
+ />
658
+ </div>
659
+
660
+ {/* FK Guid field — use EntityLookup, not <select> or <input> */}
661
+ {/* <div>
662
+ <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
663
+ {t('{module}:form.department', 'Department')}
664
+ </label>
665
+ <EntityLookup
666
+ apiEndpoint="/api/{app}/{module}/departments"
667
+ value={formData.departmentId}
668
+ onChange={(id) => setFormData(prev => prev ? { ...prev, departmentId: id } : prev)}
669
+ mapOption={(dept) => ({ label: dept.name, value: dept.id })}
670
+ placeholder={t('{module}:form.selectDepartment', 'Select a department...')}
671
+ />
672
+ </div> */}
673
+
674
+ {/* Actions */}
675
+ <div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
676
+ <button
677
+ type="button"
678
+ onClick={() => navigate(-1)}
679
+ className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]"
680
+ >
681
+ {t('common:actions.cancel', 'Cancel')}
682
+ </button>
683
+ <button
684
+ type="submit"
685
+ disabled={submitting}
686
+ 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"
687
+ >
688
+ {submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
689
+ </button>
690
+ </div>
691
+ </form>
692
+ </div>
693
+ );
694
+ }
695
+ ```
696
+
697
+ ### Lazy Loading for Form Pages
698
+
699
+ ```tsx
700
+ // In route files — form pages are also lazy-loaded
701
+ const EntityCreatePage = lazy(() =>
702
+ import('@/pages/HumanResources/Employees/EntityCreatePage')
703
+ .then(m => ({ default: m.EntityCreatePage }))
704
+ );
705
+ const EntityEditPage = lazy(() =>
706
+ import('@/pages/HumanResources/Employees/EntityEditPage')
707
+ .then(m => ({ default: m.EntityEditPage }))
708
+ );
709
+
710
+ // Route registration — form pages have their own routes
711
+ {
712
+ path: 'employees',
713
+ children: [
714
+ { index: true, element: <Suspense fallback={<PageLoader />}><EmployeesPage /></Suspense> },
715
+ { path: 'create', element: <Suspense fallback={<PageLoader />}><EntityCreatePage /></Suspense> },
716
+ { path: ':id', element: <Suspense fallback={<PageLoader />}><EntityDetailPage /></Suspense> },
717
+ { path: ':id/edit', element: <Suspense fallback={<PageLoader />}><EntityEditPage /></Suspense> },
718
+ ]
719
+ }
720
+
721
+ // Section-level routes — children of the module route (when module has sections)
722
+ //
723
+ // > **IMPORTANT:** The `list` and `detail` sections do NOT generate additional route entries.
724
+ // > They are already covered by the module's `index: true` (list) and `path: ':id'` (detail) routes above.
725
+ // > Only sections like `dashboard`, `approve`, `import`, etc. generate the section-kebab child routes below.
726
+ // Note: Do not use `path: 'list'` or `path: 'detail'` these create unreachable duplicate routes.
727
+ //
728
+ {
729
+ path: '{module-kebab}',
730
+ children: [
731
+ { index: true, element: <Suspense fallback={<PageLoader />}><{Module}Page /></Suspense> },
732
+ { path: 'create', element: <Suspense fallback={<PageLoader />}><Create{Module}Page /></Suspense> },
733
+ { path: ':id', element: <Suspense fallback={<PageLoader />}><{Module}DetailPage /></Suspense> },
734
+ { path: ':id/edit', element: <Suspense fallback={<PageLoader />}><Edit{Module}Page /></Suspense> },
735
+ // Section routes as children of module:
736
+ // IMPORTANT: "list" and "detail" are NOT separate path segments.
737
+ // - "list" section = already handled by the module's index route above (index: true)
738
+ // - "detail" section = already handled by the module's :id route above (path: ':id')
739
+ // - Only OTHER sections (dashboard, approve, import, etc.) add path segments:
740
+ { path: '{section-kebab}', element: <Suspense fallback={<PageLoader />}><{Section}Page /></Suspense> },
741
+ { path: '{section-kebab}/create', element: <Suspense fallback={<PageLoader />}><Create{Section}Page /></Suspense> },
742
+ { path: '{section-kebab}/:id', element: <Suspense fallback={<PageLoader />}><{Section}DetailPage /></Suspense> },
743
+ { path: '{section-kebab}/:id/edit', element: <Suspense fallback={<PageLoader />}><Edit{Section}Page /></Suspense> },
744
+ ]
745
+ }
746
+
747
+ // PermissionGuard for section-level routes
748
+ element: (
749
+ <Suspense fallback={<PageLoader />}>
750
+ <PermissionGuard permissions={ROUTES['app.module.section'].permissions}>
751
+ <SectionPage />
752
+ </PermissionGuard>
753
+ </Suspense>
754
+ )
755
+ ```
756
+
757
+ ### Rules
758
+
759
+ - Do not use `<Modal>`, `<Dialog>`, `<Drawer>`, or `<Popup>` for create/edit forms
760
+ - Do not use `useState(isOpen)` to toggle form visibility forms are pages, not overlays
761
+ - Create a dedicated `EntityCreatePage.tsx` and `EntityEditPage.tsx` page component
762
+ - Register create/edit routes alongside list/detail routes
763
+ - Use `navigate('create')` or `navigate(\`${id}/edit\`)` from list/detail pages
764
+ - Include a back button that uses `navigate(-1)` to return to previous page
765
+
766
+ **Incorrect patterns:**
767
+ ```tsx
768
+ // WRONG: modal for create form
769
+ const [showCreateModal, setShowCreateModal] = useState(false);
770
+ <Modal open={showCreateModal}><CreateForm /></Modal>
771
+
772
+ // WRONG: dialog for edit form
773
+ <Dialog open={editDialogOpen}><EditForm entity={selected} /></Dialog>
774
+
775
+ // WRONG: drawer for form
776
+ <Drawer open={isDrawerOpen}><form>...</form></Drawer>
777
+
778
+ // WRONG: inline form toggle
779
+ {isEditing ? <EditForm /> : <DetailView />}
780
+ ```
781
+
782
+ ---
783
+
784
+ ## 4. CSS Variables (Theme System)
785
+
786
+ Do not use hardcoded Tailwind colors. Always use CSS variables for theme support.
787
+
788
+ ### Variable Reference
789
+
790
+ | Usage | CSS Variable | Example |
791
+ |-------|-------------|---------|
792
+ | Background | `var(--bg-primary)` | `bg-[var(--bg-primary)]` |
793
+ | Card background | `var(--bg-card)` | `bg-[var(--bg-card)]` |
794
+ | Text primary | `var(--text-primary)` | `text-[var(--text-primary)]` |
795
+ | Text secondary | `var(--text-secondary)` | `text-[var(--text-secondary)]` |
796
+ | Borders | `var(--border-color)` | `border-[var(--border-color)]` |
797
+ | Accent | `var(--color-accent-500)` | `text-[var(--color-accent-500)]` |
798
+ | Card radius | `var(--radius-card)` | `style={{ borderRadius: 'var(--radius-card)' }}` |
799
+
800
+ ### Card Pattern
801
+
802
+ ```tsx
803
+ <div
804
+ className="bg-[var(--bg-card)] border border-[var(--border-color)] p-6"
805
+ style={{ borderRadius: 'var(--radius-card)' }}
806
+ >
807
+ <h2 className="text-lg font-semibold text-[var(--text-primary)]">Title</h2>
808
+ <p className="text-sm text-[var(--text-secondary)]">Description</p>
809
+ </div>
810
+ ```
811
+
812
+ **Incorrect:**
813
+ ```tsx
814
+ // Do not use hardcoded Tailwind colors
815
+ className="bg-white border-gray-200 text-gray-900"
816
+
817
+ // Do not use hardcoded hex/rgb
818
+ style={{ backgroundColor: '#ffffff', color: '#1a1a1a' }}
819
+ ```
820
+
821
+ ---
822
+
823
+ ## 5. Component Rules
824
+
825
+ | Need | Component | Source | Notes |
826
+ |------|-----------|--------|-------|
827
+ | Data table | `DataTable` | `@/components/ui/DataTable` | Shared component (sorting, pagination, search) |
828
+ | Entity cards | `EntityCard` | `@/components/ui/EntityCard` | Shared component (avatar, badges, actions) |
829
+ | FK field lookup | `EntityLookup` | Generate in `@/components/ui/EntityLookup` | See section 6 for full pattern |
830
+ | KPI statistics | `StatCard` | Generate locally per dashboard | See dashboard-chart.md pattern |
831
+ | Chart wrapper | `ChartCard` | Generate locally per dashboard | See dashboard-chart.md pattern |
832
+ | Loading spinner | `Loader2` | `lucide-react` | Shared |
833
+ | Page loader | `PageLoader` | `@/components/ui/PageLoader` | Shared (Suspense fallback) |
834
+ | Docs toggle | `DocToggleButton` | `@/components/docs/DocToggleButton` | Shared |
835
+
836
+ ### Rules
837
+
838
+ - Do not use raw `<table>` — use `DataTable` from `@/components/ui/DataTable`
839
+ - Do not create custom spinners — use `Loader2` from lucide-react
840
+ - Do not import axios directly — use `@/services/api/apiClient`
841
+ - Use `PageLoader` as Suspense fallback
842
+ - Use existing shared components before creating new ones
843
+ - Use `EntityLookup` for FK Guid fields (not `<select>` or `<input>` for GUIDs)
844
+
845
+ ---
846
+
847
+ ## 6. Foreign Key Fields & Entity Lookup
848
+
849
+ Do not render a foreign key (Guid) as a plain text input. FK fields must use a searchable lookup component.
850
+ A form asking the user to type a GUID manually is a UX failure. All FK fields must provide entity search and selection.
851
+
852
+ ### Field Type Classification
853
+
854
+ When generating form fields, determine the field type from the entity property:
855
+
856
+ | Property type | Form field type | Component |
857
+ |---------------|----------------|-----------|
858
+ | `string` | Text input | `<input type="text" />` |
859
+ | `string?` | Text input (optional) | `<input type="text" />` |
860
+ | `Guid` (FK e.g., `EmployeeId`, `DepartmentId`) | **Entity Lookup** | `<EntityLookup />` |
861
+ | `bool` | Toggle/Checkbox | `<input type="checkbox" />` |
862
+ | `int` / `decimal` | Number input | `<input type="number" />` |
863
+ | `DateTime` | Date picker | `<input type="date" />` |
864
+ | `enum` | Select dropdown | `<select>` |
865
+
866
+ **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`.
867
+
868
+ ### EntityLookup Component Pattern
869
+
870
+ ```tsx
871
+ import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
872
+ import { useTranslation } from 'react-i18next';
873
+ import { Search, X, ChevronDown } from 'lucide-react';
874
+ import { apiClient } from '@/services/api/apiClient';
875
+
876
+ interface EntityLookupOption {
877
+ id: string;
878
+ label: string; // Display name (e.g., employee full name)
879
+ sublabel?: string; // Secondary info (e.g., department, code)
880
+ }
881
+
882
+ interface EntityLookupProps {
883
+ /** API endpoint to search entities (e.g., '/api/human-resources/employees') */
884
+ apiEndpoint: string;
885
+ /** Currently selected entity ID */
886
+ value: string | null;
887
+ /** Callback when entity is selected */
888
+ onChange: (id: string | null) => void;
889
+ /** Field label */
890
+ label: string;
891
+ /** Placeholder text */
892
+ placeholder?: string;
893
+ /** Map API response item to display option */
894
+ mapOption: (item: any) => EntityLookupOption;
895
+ /** Whether the field is required */
896
+ required?: boolean;
897
+ /** Whether the field is disabled */
898
+ disabled?: boolean;
899
+ /** Error message to display */
900
+ error?: string;
901
+ }
902
+
903
+ export function EntityLookup({
904
+ apiEndpoint,
905
+ value,
906
+ onChange,
907
+ label,
908
+ placeholder,
909
+ mapOption,
910
+ required = false,
911
+ disabled = false,
912
+ error,
913
+ }: EntityLookupProps) {
914
+ const { t } = useTranslation(['common']);
915
+ const [search, setSearch] = useState('');
916
+ const [options, setOptions] = useState<EntityLookupOption[]>([]);
917
+ const [selectedOption, setSelectedOption] = useState<EntityLookupOption | null>(null);
918
+ const [isOpen, setIsOpen] = useState(false);
919
+ const [loading, setLoading] = useState(false);
920
+ const containerRef = useRef<HTMLDivElement>(null);
921
+ const debounceRef = useRef<ReturnType<typeof setTimeout>>();
922
+
923
+ // Load selected entity display on mount (when value is set but no label)
924
+ useEffect(() => {
925
+ if (value && !selectedOption) {
926
+ apiClient.get(`${apiEndpoint}/${value}`)
927
+ .then(res => setSelectedOption(mapOption(res.data)))
928
+ .catch(() => { /* Entity not found — clear */ });
929
+ }
930
+ }, [value, apiEndpoint, mapOption, selectedOption]);
931
+
932
+ // Debounced search — 300ms delay, minimum 2 characters
933
+ const handleSearch = useCallback((term: string) => {
934
+ setSearch(term);
935
+ if (debounceRef.current) clearTimeout(debounceRef.current);
936
+
937
+ if (term.length < 2) {
938
+ setOptions([]);
939
+ return;
940
+ }
941
+
942
+ debounceRef.current = setTimeout(async () => {
943
+ setLoading(true);
944
+ try {
945
+ const res = await apiClient.get(apiEndpoint, {
946
+ params: { search: term, pageSize: 20 },
947
+ });
948
+ setOptions((res.data.items || res.data).map(mapOption));
949
+ } catch {
950
+ setOptions([]);
951
+ } finally {
952
+ setLoading(false);
953
+ }
954
+ }, 300);
955
+ }, [apiEndpoint, mapOption]);
956
+
957
+ // Load initial options when dropdown opens (show first 20)
958
+ const handleOpen = useCallback(async () => {
959
+ if (disabled) return;
960
+ setIsOpen(true);
961
+ if (options.length === 0 && search.length < 2) {
962
+ setLoading(true);
963
+ try {
964
+ const res = await apiClient.get(apiEndpoint, {
965
+ params: { pageSize: 20 },
966
+ });
967
+ setOptions((res.data.items || res.data).map(mapOption));
968
+ } catch {
969
+ setOptions([]);
970
+ } finally {
971
+ setLoading(false);
972
+ }
973
+ }
974
+ }, [disabled, apiEndpoint, mapOption, options.length, search.length]);
975
+
976
+ // Select entity
977
+ const handleSelect = useCallback((option: EntityLookupOption) => {
978
+ setSelectedOption(option);
979
+ onChange(option.id);
980
+ setIsOpen(false);
981
+ setSearch('');
982
+ }, [onChange]);
983
+
984
+ // Clear selection
985
+ const handleClear = useCallback(() => {
986
+ setSelectedOption(null);
987
+ onChange(null);
988
+ setSearch('');
989
+ }, [onChange]);
990
+
991
+ // Close on outside click
992
+ useEffect(() => {
993
+ const handleClickOutside = (e: MouseEvent) => {
994
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
995
+ setIsOpen(false);
996
+ }
997
+ };
998
+ document.addEventListener('mousedown', handleClickOutside);
999
+ return () => document.removeEventListener('mousedown', handleClickOutside);
1000
+ }, []);
1001
+
1002
+ return (
1003
+ <div ref={containerRef} className="relative">
1004
+ <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
1005
+ {label} {required && <span className="text-[var(--error-text)]">*</span>}
1006
+ </label>
1007
+
1008
+ {/* Selected value display OR search input */}
1009
+ {selectedOption && !isOpen ? (
1010
+ <div className="flex items-center gap-2 px-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)]">
1011
+ <div className="flex-1">
1012
+ <span className="text-[var(--text-primary)]">{selectedOption.label}</span>
1013
+ {selectedOption.sublabel && (
1014
+ <span className="ml-2 text-sm text-[var(--text-secondary)]">{selectedOption.sublabel}</span>
1015
+ )}
1016
+ </div>
1017
+ {!disabled && (
1018
+ <button type="button" onClick={handleClear} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
1019
+ <X className="w-4 h-4" />
1020
+ </button>
1021
+ )}
1022
+ <button type="button" onClick={handleOpen} className="text-[var(--text-secondary)]">
1023
+ <ChevronDown className="w-4 h-4" />
1024
+ </button>
1025
+ </div>
1026
+ ) : (
1027
+ <div className="relative">
1028
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" />
1029
+ <input
1030
+ type="text"
1031
+ value={search}
1032
+ onChange={(e) => handleSearch(e.target.value)}
1033
+ onFocus={handleOpen}
1034
+ placeholder={placeholder || t('common:actions.search', 'Search...')}
1035
+ disabled={disabled}
1036
+ 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"
1037
+ />
1038
+ </div>
1039
+ )}
1040
+
1041
+ {/* Dropdown */}
1042
+ {isOpen && (
1043
+ <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">
1044
+ {loading ? (
1045
+ <div className="p-3 text-center text-[var(--text-secondary)]">
1046
+ {t('common:actions.loading', 'Loading...')}
1047
+ </div>
1048
+ ) : options.length === 0 ? (
1049
+ <div className="p-3 text-center text-[var(--text-secondary)]">
1050
+ {search.length < 2
1051
+ ? t('common:actions.typeToSearch', 'Type at least 2 characters to search...')
1052
+ : t('common:empty.noResults', 'No results found')}
1053
+ </div>
1054
+ ) : (
1055
+ options.map((option) => (
1056
+ <button
1057
+ key={option.id}
1058
+ type="button"
1059
+ onClick={() => handleSelect(option)}
1060
+ className="w-full px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
1061
+ >
1062
+ <div className="text-[var(--text-primary)]">{option.label}</div>
1063
+ {option.sublabel && (
1064
+ <div className="text-sm text-[var(--text-secondary)]">{option.sublabel}</div>
1065
+ )}
1066
+ </button>
1067
+ ))
1068
+ )}
1069
+ </div>
1070
+ )}
1071
+
1072
+ {/* Error message */}
1073
+ {error && (
1074
+ <p className="mt-1 text-sm text-[var(--error-text)]">{error}</p>
1075
+ )}
1076
+ </div>
1077
+ );
1078
+ }
1079
+ ```
1080
+
1081
+ ### Usage in Form Pages
1082
+
1083
+ ```tsx
1084
+ // In EntityCreatePage.tsx or EntityEditPage.tsx
1085
+ import { EntityLookup } from '@/components/ui/EntityLookup';
1086
+
1087
+ // Inside the form:
1088
+ <EntityLookup
1089
+ apiEndpoint="/api/human-resources/employees"
1090
+ value={formData.employeeId}
1091
+ onChange={(id) => handleChange('employeeId', id)}
1092
+ label={t('module:form.employee', 'Employee')}
1093
+ placeholder={t('module:form.employeePlaceholder', 'Search for an employee...')}
1094
+ mapOption={(emp) => ({
1095
+ id: emp.id,
1096
+ label: `${emp.firstName} ${emp.lastName}`,
1097
+ sublabel: emp.department || emp.code,
1098
+ })}
1099
+ required
1100
+ error={errors.employeeId}
1101
+ />
1102
+
1103
+ // For DepartmentId FK:
1104
+ <EntityLookup
1105
+ apiEndpoint="/api/human-resources/departments"
1106
+ value={formData.departmentId}
1107
+ onChange={(id) => handleChange('departmentId', id)}
1108
+ label={t('module:form.department', 'Department')}
1109
+ placeholder={t('module:form.departmentPlaceholder', 'Search for a department...')}
1110
+ mapOption={(dept) => ({
1111
+ id: dept.id,
1112
+ label: dept.name,
1113
+ sublabel: dept.code,
1114
+ })}
1115
+ required
1116
+ />
1117
+ ```
1118
+
1119
+ ### API Search Endpoint Convention (Backend)
1120
+
1121
+ For EntityLookup to work, each entity's API MUST support search via query parameter:
1122
+
1123
+ ```
1124
+ GET /api/{resource}?search={term}&pageSize=20
1125
+ ```
1126
+
1127
+ Response format:
1128
+ ```json
1129
+ {
1130
+ "items": [
1131
+ { "id": "guid", "code": "EMP001", "name": "John Doe", ... }
1132
+ ],
1133
+ "totalCount": 42
1134
+ }
1135
+ ```
1136
+
1137
+ The backend service's `GetAllAsync` method should accept search parameters:
1138
+
1139
+ ```csharp
1140
+ public async Task<PaginatedResult<EntityResponseDto>> GetAllAsync(
1141
+ string? search = null,
1142
+ int page = 1,
1143
+ int pageSize = 20,
1144
+ CancellationToken ct = default)
1145
+ {
1146
+ var query = _db.Entities
1147
+ .Where(x => x.TenantId == _currentUser.TenantId);
1148
+
1149
+ if (!string.IsNullOrWhiteSpace(search))
1150
+ {
1151
+ query = query.Where(x =>
1152
+ x.Name.Contains(search) ||
1153
+ x.Code.Contains(search));
1154
+ }
1155
+
1156
+ var totalCount = await query.CountAsync(ct);
1157
+ var items = await query
1158
+ .OrderBy(x => x.Name)
1159
+ .Skip((page - 1) * pageSize)
1160
+ .Take(pageSize)
1161
+ .Select(x => new EntityResponseDto { ... })
1162
+ .ToListAsync(ct);
1163
+
1164
+ return new PaginatedResult<EntityResponseDto>(items, totalCount, page, pageSize);
1165
+ }
1166
+ ```
1167
+
1168
+ ### Rules
1169
+
1170
+ - Do not render a `Guid` FK field as `<input type="text">` — use `EntityLookup`
1171
+ - Do not render a `Guid` FK field as `<select>` — even with API-loaded `<option>` elements, `<select>` is not acceptable
1172
+ - Do not ask the user to manually type or paste a GUID/ID
1173
+ - Provide a search-based selection via `<EntityLookup />` for FK fields
1174
+ - Show the entity's display name (Name, FullName, Code+Name) not the GUID
1175
+ - Include `mapOption` to define how the related entity is displayed
1176
+ - Load the selected entity's display name on mount (for edit forms)
1177
+ - Support clearing the selection (unless required + already set)
1178
+
1179
+ **Why `<select>` is NOT acceptable for FK fields:**
1180
+ - `<select>` loads ALL options at once — fails with 100+ entities (performance + UX)
1181
+ - `<select>` has no search/filteruser must scroll through all options
1182
+ - `<select>` cannot show sublabels (code, department, etc.)
1183
+ - `EntityLookup` provides: debounced API search, paginated results, display name resolution, sublabels
1184
+
1185
+ **Incorrect patterns:**
1186
+ ```tsx
1187
+ // Do not use plain text input for FK field
1188
+ <input
1189
+ type="text"
1190
+ value={formData.employeeId}
1191
+ onChange={(e) => handleChange('employeeId', e.target.value)}
1192
+ placeholder="Enter Employee ID..."
1193
+ />
1194
+
1195
+ // Do not use <select> dropdown for FK field (even with API-loaded options)
1196
+ <select
1197
+ value={formData.departmentId}
1198
+ onChange={(e) => setFormData({ ...formData, departmentId: e.target.value })}
1199
+ >
1200
+ <option value="">Select Department...</option>
1201
+ {departments.map((dept) => (
1202
+ <option key={dept.id} value={dept.id}>{dept.name}</option>
1203
+ ))}
1204
+ </select>
1205
+
1206
+ // Do not display raw GUID to user
1207
+ <span>{entity.departmentId}</span>
1208
+
1209
+ // Do not use select with hardcoded options for FK
1210
+ <select onChange={(e) => handleChange('departmentId', e.target.value)}>
1211
+ <option value="guid-1">Department A</option>
1212
+ </select>
1213
+ ```
1214
+
1215
+ **Correct pattern:**
1216
+ ```tsx
1217
+ <EntityLookup
1218
+ apiEndpoint="/api/human-resources/departments"
1219
+ value={formData.departmentId}
1220
+ onChange={(id) => handleChange('departmentId', id)}
1221
+ label={t('module:form.department', 'Department')}
1222
+ mapOption={(dept) => ({ id: dept.id, label: dept.name, sublabel: dept.code })}
1223
+ required
1224
+ />
1225
+ ```
1226
+
1227
+ ### I18n Keys for EntityLookup
1228
+
1229
+ Add these keys to the module's translation files:
1230
+
1231
+ ```json
1232
+ {
1233
+ "form": {
1234
+ "employee": "Employee",
1235
+ "employeePlaceholder": "Search for an employee...",
1236
+ "department": "Department",
1237
+ "departmentPlaceholder": "Search for a department..."
1238
+ }
1239
+ }
1240
+ ```
1241
+
1242
+ ---
1243
+
1244
+ > **Sections 7-9 (Documentation, Testing, Compliance Gates) have been moved to `references/smartstack-frontend-compliance.md` for context reduction.**
1245
+ > Load that file for: DocToggleButton integration (§7), frontend checklist (§7b), cross-tenant UI patterns (§7c), form testing templates (§8), and 5 compliance gates (§9).
1246
+