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