@atlashub/smartstack-cli 4.17.1 → 4.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/agents/ba-reader.md +86 -80
- package/templates/agents/ba-writer.md +321 -413
- package/templates/agents/docs-context-reader.md +3 -3
- package/templates/mcp-scaffolding/frontend/nav-routes.ts.hbs +133 -0
- package/templates/mcp-scaffolding/frontend/routes.tsx.hbs +126 -0
- package/templates/skills/apex/SKILL.md +29 -16
- package/templates/skills/apex/_shared.md +62 -9
- package/templates/skills/apex/references/analysis-methods.md +8 -6
- package/templates/skills/apex/references/challenge-questions.md +5 -5
- package/templates/skills/apex/references/core-seed-data.md +68 -45
- package/templates/skills/apex/references/frontend-route-wiring-app-tsx.md +26 -21
- package/templates/skills/apex/references/parallel-execution.md +156 -0
- package/templates/skills/apex/references/person-extension-pattern.md +12 -12
- package/templates/skills/apex/references/post-checks.md +1748 -1726
- package/templates/skills/apex/references/smartstack-api.md +63 -57
- package/templates/skills/apex/references/smartstack-frontend-compliance.md +594 -0
- package/templates/skills/apex/references/smartstack-frontend.md +1246 -1842
- package/templates/skills/apex/references/smartstack-layers.md +98 -145
- package/templates/skills/apex/steps/step-00-init.md +30 -6
- package/templates/skills/apex/steps/step-01-analyze.md +27 -23
- package/templates/skills/apex/steps/step-02-plan.md +12 -12
- package/templates/skills/apex/steps/step-03-execute.md +198 -143
- package/templates/skills/apex/steps/step-04-examine.md +24 -93
- package/templates/skills/apex/steps/step-05-deep-review.md +16 -16
- package/templates/skills/apex/steps/step-06-resolve.md +9 -9
- package/templates/skills/apex/steps/step-07-tests.md +3 -1
- package/templates/skills/apex/steps/step-08-run-tests.md +1 -1
- package/templates/skills/business-analyse/SKILL.md +182 -301
- package/templates/skills/business-analyse/_shared.md +119 -336
- package/templates/skills/business-analyse/html/ba-interactive.html +703 -82
- package/templates/skills/business-analyse/html/build-html.js +41 -3
- package/templates/skills/business-analyse/html/src/partials/cadrage-context.html +34 -0
- package/templates/skills/business-analyse/html/src/partials/cadrage-risks.html +48 -0
- package/templates/skills/business-analyse/html/src/partials/cadrage-scope.html +49 -0
- package/templates/skills/business-analyse/html/src/partials/cadrage-stakeholders.html +55 -0
- package/templates/skills/business-analyse/html/src/partials/cadrage-success.html +34 -0
- package/templates/skills/business-analyse/html/src/partials/consol-datamodel.html +8 -0
- package/templates/skills/business-analyse/html/src/partials/consol-flows.html +29 -0
- package/templates/skills/business-analyse/html/src/partials/consol-interactions.html +8 -0
- package/templates/skills/business-analyse/html/src/partials/consol-permissions.html +8 -0
- package/templates/skills/business-analyse/html/src/partials/decomp-dependencies.html +38 -0
- package/templates/skills/business-analyse/html/src/partials/decomp-modules.html +51 -0
- package/templates/skills/business-analyse/html/src/partials/handoff-summary.html +24 -0
- package/templates/skills/business-analyse/html/src/partials/module-spec-container.html +4 -0
- package/templates/skills/business-analyse/html/src/scripts/01-data-init.js +17 -1
- package/templates/skills/business-analyse/html/src/scripts/02-navigation.js +31 -5
- package/templates/skills/business-analyse/html/src/scripts/05-render-specs.js +100 -63
- package/templates/skills/business-analyse/html/src/scripts/06-render-mockups.js +372 -0
- package/templates/skills/business-analyse/html/src/scripts/10-comments.js +41 -13
- package/templates/skills/business-analyse/html/src/styles/09-mockups-html.css +136 -0
- package/templates/skills/business-analyse/patterns/suggestion-catalog.md +7 -5
- package/templates/skills/business-analyse/questionnaire/02-stakeholders-scope.md +142 -0
- package/templates/skills/business-analyse/questionnaire/03-data-ui.md +94 -0
- package/templates/skills/business-analyse/questionnaire/04-risks-metrics.md +150 -0
- package/templates/skills/business-analyse/questionnaire/05-cross-module.md +69 -0
- package/templates/skills/business-analyse/questionnaire.md +23 -280
- package/templates/skills/business-analyse/react/application-viewer.md +2 -2
- package/templates/skills/business-analyse/react/components.md +4 -4
- package/templates/skills/business-analyse/react/i18n-template.md +1 -1
- package/templates/skills/business-analyse/react/schema.md +14 -14
- package/templates/skills/business-analyse/references/acceptance-criteria.md +25 -25
- package/templates/skills/business-analyse/references/analysis-semantic-checks.md +3 -3
- package/templates/skills/business-analyse/references/compilation-structure-cards.md +1 -1
- package/templates/skills/business-analyse/references/consolidation-structural-checks.md +7 -7
- package/templates/skills/business-analyse/references/deploy-data-build.md +14 -12
- package/templates/skills/business-analyse/references/deploy-modes.md +10 -10
- package/templates/skills/business-analyse/references/detection-strategies.md +6 -6
- package/templates/skills/business-analyse/references/html-data-mapping.md +15 -15
- package/templates/skills/business-analyse/references/naming-conventions.md +4 -4
- package/templates/skills/business-analyse/references/review-data-mapping.md +29 -29
- package/templates/skills/business-analyse/references/robustness-checks.md +36 -33
- package/templates/skills/business-analyse/references/spec-auto-inference.md +2 -2
- package/templates/skills/business-analyse/references/ui-dashboard-spec.md +1 -1
- package/templates/skills/business-analyse/references/ui-resource-cards.md +1 -1
- package/templates/skills/business-analyse/references/validation-checklist.md +9 -6
- package/templates/skills/business-analyse/references/wireframe-svg-style-guide.md +2 -2
- package/templates/skills/business-analyse/schemas/application-schema.json +8 -8
- package/templates/skills/business-analyse/schemas/feature-schema.json +3 -3
- package/templates/skills/business-analyse/schemas/index-schema.json +47 -0
- package/templates/skills/business-analyse/schemas/project-schema.json +6 -6
- package/templates/skills/business-analyse/schemas/sections/analysis-schema.json +1 -1
- package/templates/skills/business-analyse/schemas/sections/handoff-schema.json +5 -3
- package/templates/skills/business-analyse/schemas/sections/metadata-schema.json +4 -4
- package/templates/skills/business-analyse/schemas/sections/specification-schema.json +1 -1
- package/templates/skills/business-analyse/schemas/shared/common-defs.json +4 -3
- package/templates/skills/business-analyse/steps/step-00-init.md +93 -134
- package/templates/skills/business-analyse/steps/step-01-cadrage.md +136 -172
- package/templates/skills/business-analyse/steps/step-02-structure.md +175 -0
- package/templates/skills/business-analyse/steps/step-03-specify.md +198 -0
- package/templates/skills/business-analyse/steps/step-04-consolidate.md +478 -0
- package/templates/skills/business-analyse/steps/step-05-deploy.md +220 -0
- package/templates/skills/business-analyse/steps/step-06-review.md +51 -69
- package/templates/skills/business-analyse/templates/tpl-frd.md +1 -1
- package/templates/skills/business-analyse/templates/tpl-handoff.md +20 -17
- package/templates/skills/business-analyse/templates/tpl-launch-displays.md +2 -2
- package/templates/skills/business-analyse/templates-react.md +2 -2
- package/templates/skills/derive-prd/SKILL.md +92 -0
- package/templates/skills/derive-prd/references/acceptance-criteria.md +169 -0
- package/templates/skills/derive-prd/references/entity-domain-mapping.md +115 -0
- package/templates/skills/{business-analyse → derive-prd}/references/handoff-file-templates.md +131 -120
- package/templates/skills/{business-analyse → derive-prd}/references/handoff-mappings.md +95 -95
- package/templates/skills/{business-analyse → derive-prd}/references/handoff-seeddata-generation.md +312 -312
- package/templates/skills/{business-analyse → derive-prd}/references/prd-generation.md +262 -258
- package/templates/skills/derive-prd/references/readiness-scoring.md +104 -0
- package/templates/skills/derive-prd/schemas/handoff-schema.json +95 -0
- package/templates/skills/derive-prd/steps/step-00-validate.md +130 -0
- package/templates/skills/derive-prd/steps/step-01-transform.md +206 -0
- package/templates/skills/derive-prd/steps/step-02-export.md +181 -0
- package/templates/skills/{business-analyse → derive-prd}/templates/tpl-progress.md +172 -172
- package/templates/skills/ralph-loop/SKILL.md +10 -4
- package/templates/skills/ralph-loop/references/category-completeness.md +20 -4
- package/templates/skills/ralph-loop/references/compact-loop.md +80 -48
- package/templates/skills/ralph-loop/references/init-resume-recovery.md +4 -2
- package/templates/skills/ralph-loop/references/parallel-execution.md +27 -27
- package/templates/skills/ralph-loop/steps/step-00-init.md +19 -9
- package/templates/skills/ralph-loop/steps/step-01-task.md +12 -4
- package/templates/skills/ralph-loop/steps/step-02-execute.md +9 -4
- package/templates/skills/ralph-loop/steps/step-03-commit.md +1 -1
- package/templates/skills/ralph-loop/steps/step-04-check.md +5 -21
- package/templates/skills/ralph-loop/steps/step-05-report.md +6 -1
- package/templates/skills/apex/references/agent-teams-protocol.md +0 -203
- package/templates/skills/business-analyse/_architecture.md +0 -124
- package/templates/skills/business-analyse/_elicitation.md +0 -206
- package/templates/skills/business-analyse/_module-loop.md +0 -115
- package/templates/skills/business-analyse/_suggestions.md +0 -34
- package/templates/skills/business-analyse/questionnaire/00-application.md +0 -160
- package/templates/skills/business-analyse/questionnaire/00b-project.md +0 -85
- package/templates/skills/business-analyse/questionnaire/02-stakeholders.md +0 -189
- package/templates/skills/business-analyse/questionnaire/03-scope.md +0 -164
- package/templates/skills/business-analyse/questionnaire/04-data.md +0 -88
- package/templates/skills/business-analyse/questionnaire/05-integrations.md +0 -58
- package/templates/skills/business-analyse/questionnaire/06-security.md +0 -68
- package/templates/skills/business-analyse/questionnaire/07-ui.md +0 -76
- package/templates/skills/business-analyse/questionnaire/08-performance.md +0 -42
- package/templates/skills/business-analyse/questionnaire/09-constraints.md +0 -45
- package/templates/skills/business-analyse/questionnaire/10-documentation.md +0 -43
- package/templates/skills/business-analyse/questionnaire/11-data-lifecycle.md +0 -59
- package/templates/skills/business-analyse/questionnaire/12-migration.md +0 -58
- package/templates/skills/business-analyse/questionnaire/13-cross-module.md +0 -69
- package/templates/skills/business-analyse/questionnaire/14-risk-assumptions.md +0 -135
- package/templates/skills/business-analyse/questionnaire/15-success-metrics.md +0 -136
- package/templates/skills/business-analyse/references/agent-module-prompt.md +0 -362
- package/templates/skills/business-analyse/references/agent-pooling-best-practices.md +0 -557
- package/templates/skills/business-analyse/references/cache-warming-strategy.md +0 -566
- package/templates/skills/business-analyse/references/cadrage-challenge-patterns.md +0 -41
- package/templates/skills/business-analyse/references/cadrage-coverage-matrix.md +0 -74
- package/templates/skills/business-analyse/references/cadrage-pre-analysis.md +0 -115
- package/templates/skills/business-analyse/references/cadrage-shared-modules.md +0 -68
- package/templates/skills/business-analyse/references/cadrage-structure-cards.md +0 -85
- package/templates/skills/business-analyse/references/team-orchestration.md +0 -1022
- package/templates/skills/business-analyse/references/validate-incremental-html.md +0 -121
- package/templates/skills/business-analyse/steps/step-01b-applications.md +0 -419
- package/templates/skills/business-analyse/steps/step-02-decomposition.md +0 -387
- package/templates/skills/business-analyse/steps/step-03a-data.md +0 -16
- package/templates/skills/business-analyse/steps/step-03a1-setup.md +0 -506
- package/templates/skills/business-analyse/steps/step-03a2-analysis.md +0 -301
- package/templates/skills/business-analyse/steps/step-03b-ui.md +0 -425
- package/templates/skills/business-analyse/steps/step-03c-compile.md +0 -611
- package/templates/skills/business-analyse/steps/step-03d-validate.md +0 -783
- package/templates/skills/business-analyse/steps/step-04-consolidation.md +0 -17
- package/templates/skills/business-analyse/steps/step-04a-collect.md +0 -415
- package/templates/skills/business-analyse/steps/step-04b-analyze.md +0 -163
- package/templates/skills/business-analyse/steps/step-04c-decide.md +0 -186
- package/templates/skills/business-analyse/steps/step-05a-handoff.md +0 -840
- package/templates/skills/business-analyse/steps/step-05b-deploy.md +0 -522
- 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
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
- The unified AppLayout component is ALSO lazy-loaded
|
|
48
|
-
|
|
49
|
-
**
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
**
|
|
66
|
-
```tsx
|
|
67
|
-
const ClientsListPage = lazy(() =>
|
|
68
|
-
import('@/pages/HumanResources/Clients/ClientsListPage')
|
|
69
|
-
.then(m => ({ default: m.ClientsListPage }))
|
|
70
|
-
);
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
**
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
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
|
|
215
|
-
|
|
216
|
-
### Rules
|
|
217
|
-
|
|
218
|
-
-
|
|
219
|
-
-
|
|
220
|
-
-
|
|
221
|
-
-
|
|
222
|
-
-
|
|
223
|
-
-
|
|
224
|
-
|
|
225
|
-
**
|
|
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 (
|
|
388
|
-
const handleEdit = () => navigate(`edit`);
|
|
389
|
-
|
|
390
|
-
// ... loading/error/content pattern
|
|
391
|
-
}
|
|
392
|
-
```
|
|
393
|
-
|
|
394
|
-
### Tab Behavior Rules
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
**Tab state management:**
|
|
401
|
-
- Tabs use `useState<TabKey>('info')` for the active tab —
|
|
402
|
-
- Tab click handler: `onClick={() => setActiveTab(tabKey)}` —
|
|
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
|
-
**
|
|
421
|
-
```tsx
|
|
422
|
-
//
|
|
423
|
-
const handleTabClick = (tab: TabKey) => {
|
|
424
|
-
setActiveTab(tab);
|
|
425
|
-
if (tab === 'leaves') navigate(`../leaves?employee=${id}`); // ←
|
|
426
|
-
};
|
|
427
|
-
|
|
428
|
-
//
|
|
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
|
|
439
|
-
|
|
440
|
-
---
|
|
441
|
-
|
|
442
|
-
## 3b. Form Pages Pattern (Create / Edit)
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
import {
|
|
463
|
-
import {
|
|
464
|
-
import {
|
|
465
|
-
import {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
const
|
|
471
|
-
const [
|
|
472
|
-
const [
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
{
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
{
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
{/*
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
disabled
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
import {
|
|
572
|
-
import {
|
|
573
|
-
import {
|
|
574
|
-
import {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
const {
|
|
579
|
-
const
|
|
580
|
-
const
|
|
581
|
-
const [
|
|
582
|
-
const [
|
|
583
|
-
const [
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
{
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
{
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
{/*
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
disabled
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
)
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
{
|
|
716
|
-
{ path: '
|
|
717
|
-
{ path: ':id', element: <Suspense fallback={<PageLoader />}><
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
// >
|
|
725
|
-
// >
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
|
|
729
|
-
{
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
{
|
|
733
|
-
{ path: '
|
|
734
|
-
{ path: ':id', element: <Suspense fallback={<PageLoader />}><{Module}
|
|
735
|
-
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
// - "
|
|
739
|
-
// -
|
|
740
|
-
|
|
741
|
-
{ path: '{section-kebab}', element: <Suspense fallback={<PageLoader />}><{Section}Page /></Suspense> },
|
|
742
|
-
{ path: '{section-kebab}
|
|
743
|
-
{ path: '{section-kebab}/:id', element: <Suspense fallback={<PageLoader />}><{Section}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
-
|
|
761
|
-
-
|
|
762
|
-
-
|
|
763
|
-
-
|
|
764
|
-
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
|
794
|
-
|
|
|
795
|
-
| Text
|
|
796
|
-
|
|
|
797
|
-
|
|
|
798
|
-
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
>
|
|
808
|
-
<
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
|
829
|
-
|
|
|
830
|
-
|
|
|
831
|
-
|
|
|
832
|
-
|
|
|
833
|
-
|
|
|
834
|
-
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
-
|
|
840
|
-
-
|
|
841
|
-
-
|
|
842
|
-
-
|
|
843
|
-
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
| `string
|
|
860
|
-
| `
|
|
861
|
-
| `
|
|
862
|
-
| `
|
|
863
|
-
| `
|
|
864
|
-
| `
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
import {
|
|
873
|
-
import {
|
|
874
|
-
import {
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
}
|
|
915
|
-
const
|
|
916
|
-
const [
|
|
917
|
-
const [
|
|
918
|
-
const [
|
|
919
|
-
const [
|
|
920
|
-
const
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
.
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
document.
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
{
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
)
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
<
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
{
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
{
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
int
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
x.
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
var
|
|
1158
|
-
|
|
1159
|
-
.
|
|
1160
|
-
.
|
|
1161
|
-
.
|
|
1162
|
-
.
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
-
|
|
1172
|
-
-
|
|
1173
|
-
-
|
|
1174
|
-
-
|
|
1175
|
-
-
|
|
1176
|
-
-
|
|
1177
|
-
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
- `<select>`
|
|
1182
|
-
- `<select>`
|
|
1183
|
-
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
>
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
<
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
{
|
|
1234
|
-
|
|
1235
|
-
"
|
|
1236
|
-
"
|
|
1237
|
-
"
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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/filter — user 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
|
+
|