@atlashub/smartstack-cli 3.30.0 → 3.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.documentation/installation.html +7 -2
  2. package/README.md +7 -1
  3. package/dist/index.js +33 -37
  4. package/dist/index.js.map +1 -1
  5. package/dist/mcp-entry.mjs +547 -97
  6. package/dist/mcp-entry.mjs.map +1 -1
  7. package/package.json +1 -1
  8. package/scripts/health-check.sh +2 -1
  9. package/templates/mcp-scaffolding/controller.cs.hbs +10 -7
  10. package/templates/mcp-scaffolding/entity-extension.cs.hbs +132 -124
  11. package/templates/mcp-scaffolding/frontend/api-client.ts.hbs +4 -4
  12. package/templates/mcp-scaffolding/tests/controller.test.cs.hbs +38 -15
  13. package/templates/mcp-scaffolding/tests/service.test.cs.hbs +20 -8
  14. package/templates/skills/apex/SKILL.md +7 -9
  15. package/templates/skills/apex/_shared.md +9 -2
  16. package/templates/skills/apex/references/code-generation.md +412 -0
  17. package/templates/skills/apex/references/post-checks.md +377 -37
  18. package/templates/skills/apex/references/smartstack-api.md +229 -5
  19. package/templates/skills/apex/references/smartstack-frontend.md +368 -11
  20. package/templates/skills/apex/references/smartstack-layers.md +54 -7
  21. package/templates/skills/apex/steps/step-00-init.md +1 -2
  22. package/templates/skills/apex/steps/step-01-analyze.md +45 -2
  23. package/templates/skills/apex/steps/step-02-plan.md +23 -2
  24. package/templates/skills/apex/steps/step-03-execute.md +195 -5
  25. package/templates/skills/apex/steps/step-04-examine.md +18 -5
  26. package/templates/skills/apex/steps/step-05-deep-review.md +9 -11
  27. package/templates/skills/apex/steps/step-06-resolve.md +5 -9
  28. package/templates/skills/apex/steps/step-07-tests.md +66 -1
  29. package/templates/skills/apex/steps/step-08-run-tests.md +12 -3
  30. package/templates/skills/application/references/provider-template.md +62 -39
  31. package/templates/skills/application/templates-backend.md +3 -3
  32. package/templates/skills/application/templates-frontend.md +12 -12
  33. package/templates/skills/application/templates-seed.md +14 -4
  34. package/templates/skills/business-analyse/SKILL.md +10 -7
  35. package/templates/skills/business-analyse/questionnaire/04-data.md +8 -0
  36. package/templates/skills/business-analyse/references/agent-module-prompt.md +84 -5
  37. package/templates/skills/business-analyse/references/agent-pooling-best-practices.md +83 -19
  38. package/templates/skills/business-analyse/references/consolidation-structural-checks.md +6 -2
  39. package/templates/skills/business-analyse/references/team-orchestration.md +470 -113
  40. package/templates/skills/business-analyse/references/validation-checklist.md +5 -4
  41. package/templates/skills/business-analyse/schemas/sections/analysis-schema.json +44 -0
  42. package/templates/skills/business-analyse/steps/step-03a2-analysis.md +72 -1
  43. package/templates/skills/business-analyse/steps/step-03c-compile.md +93 -7
  44. package/templates/skills/business-analyse/steps/step-03d-validate.md +34 -2
  45. package/templates/skills/business-analyse/steps/step-04b-analyze.md +40 -0
  46. package/templates/skills/controller/references/controller-code-templates.md +2 -2
  47. package/templates/skills/controller/templates.md +12 -12
  48. package/templates/skills/feature-full/steps/step-01-implementation.md +4 -4
  49. package/templates/skills/ralph-loop/references/category-rules.md +44 -2
  50. package/templates/skills/ralph-loop/references/compact-loop.md +37 -0
  51. package/templates/skills/ralph-loop/references/core-seed-data.md +51 -20
  52. package/templates/skills/review-code/references/owasp-api-top10.md +1 -1
@@ -151,6 +151,94 @@ public class {Name} : BaseEntity, IAuditableEntity
151
151
  }
152
152
  ```
153
153
 
154
+ ### Entity Pattern — Cross-Tenant (IOptionalTenantEntity)
155
+
156
+ For entities that can be shared across tenants (e.g., Department, Currency). TenantId is nullable — null means shared, Guid means tenant-specific. The user decides the scope at creation time.
157
+
158
+ ```csharp
159
+ public class {Name} : BaseEntity, IOptionalTenantEntity, IAuditableEntity
160
+ {
161
+ // TenantId nullable — null = shared across all tenants
162
+ public Guid? TenantId { get; private set; }
163
+
164
+ public string? CreatedBy { get; set; }
165
+ public string? UpdatedBy { get; set; }
166
+
167
+ // Business properties
168
+ public string Code { get; private set; } = string.Empty;
169
+ public string Name { get; private set; } = string.Empty;
170
+
171
+ private {Name}() { }
172
+
173
+ /// <param name="tenantId">null = shared (cross-tenant), Guid = tenant-specific</param>
174
+ public static {Name} Create(Guid? tenantId = null, string code, string name)
175
+ {
176
+ return new {Name}
177
+ {
178
+ Id = Guid.NewGuid(),
179
+ TenantId = tenantId,
180
+ Code = code.ToLowerInvariant(),
181
+ Name = name,
182
+ CreatedAt = DateTime.UtcNow
183
+ };
184
+ }
185
+ }
186
+ ```
187
+
188
+ **EF Core global query filter (already in SmartStack.app CoreDbContext):**
189
+ ```csharp
190
+ builder.HasQueryFilter(e => !ShouldFilterByTenant || e.TenantId == null || e.TenantId == CurrentTenantId);
191
+ ```
192
+ This automatically includes shared (null) + current tenant data in all queries.
193
+
194
+ **Service pattern for optional tenant:**
195
+ ```csharp
196
+ // No guard clause — tenantId is nullable
197
+ var tenantId = _currentTenant.TenantId; // null = creating shared data
198
+ var entity = Department.Create(tenantId, dto.Code, dto.Name);
199
+ ```
200
+
201
+ ### Entity Pattern — Scoped (IScopedTenantEntity)
202
+
203
+ For entities with explicit visibility control via EntityScope enum (Tenant, Shared, Platform).
204
+
205
+ ```csharp
206
+ public class {Name} : BaseEntity, IScopedTenantEntity, IAuditableEntity
207
+ {
208
+ public Guid? TenantId { get; private set; }
209
+ public EntityScope Scope { get; private set; }
210
+
211
+ public string? CreatedBy { get; set; }
212
+ public string? UpdatedBy { get; set; }
213
+
214
+ private {Name}() { }
215
+
216
+ public static {Name} Create(Guid? tenantId = null, EntityScope scope = EntityScope.Tenant)
217
+ {
218
+ if (scope == EntityScope.Tenant && tenantId == null)
219
+ throw new ArgumentException("TenantId is required when scope is Tenant");
220
+
221
+ return new {Name}
222
+ {
223
+ Id = Guid.NewGuid(),
224
+ TenantId = tenantId,
225
+ Scope = scope,
226
+ CreatedAt = DateTime.UtcNow
227
+ };
228
+ }
229
+ }
230
+ ```
231
+
232
+ ### MCP tenantMode Parameter
233
+
234
+ When calling `scaffold_extension`, use the `tenantMode` parameter:
235
+ - `strict` (default) — ITenantEntity, Guid TenantId (required)
236
+ - `optional` — IOptionalTenantEntity, Guid? TenantId (cross-tenant)
237
+ - `scoped` — IScopedTenantEntity, Guid? TenantId + EntityScope
238
+ - `none` — No tenant interface (platform-level entities)
239
+
240
+ The old `isSystemEntity: true` still works and maps to `tenantMode: 'none'`.
241
+
154
242
  ---
155
243
 
156
244
  ## EF Configuration Pattern
@@ -599,6 +687,130 @@ services.AddValidatorsFromAssemblyContaining<Create{Name}DtoValidator>();
599
687
  | `Permission.Create()` | Does NOT exist — use `CreateForModule()`, `CreateForSection()`, etc. |
600
688
  | `GetAllAsync()` without search param | ALL GetAll endpoints MUST support `?search=` for EntityLookup |
601
689
  | FK field as plain text input | Frontend MUST use `EntityLookup` component for Guid FK fields |
690
+ | `PagedResult<T>` / `PaginatedResultDto<T>` | FORBIDDEN — use `PaginatedResult<T>` only |
691
+
692
+ ---
693
+
694
+ ## PaginatedResult Pattern
695
+
696
+ > **Canonical type for ALL paginated responses.** One name, one contract, everywhere.
697
+
698
+ ### Definition (Backend — `SmartStack.Application.Common.Models`)
699
+
700
+ ```csharp
701
+ namespace SmartStack.Application.Common.Models;
702
+
703
+ public record PaginatedResult<T>(
704
+ List<T> Items,
705
+ int TotalCount,
706
+ int Page,
707
+ int PageSize)
708
+ {
709
+ public int TotalPages => PageSize > 0
710
+ ? (int)Math.Ceiling((double)TotalCount / PageSize) : 0;
711
+ public bool HasPreviousPage => Page > 1;
712
+ public bool HasNextPage => Page < TotalPages;
713
+
714
+ public static PaginatedResult<T> Empty(int page = 1, int pageSize = 20)
715
+ => new([], 0, page, pageSize);
716
+ }
717
+ ```
718
+
719
+ ### Extension Method
720
+
721
+ ```csharp
722
+ namespace SmartStack.Application.Common.Extensions;
723
+
724
+ public static class QueryableExtensions
725
+ {
726
+ public const int MaxPageSize = 100;
727
+
728
+ public static async Task<PaginatedResult<T>> ToPaginatedResultAsync<T>(
729
+ this IQueryable<T> query,
730
+ int page = 1,
731
+ int pageSize = 20,
732
+ CancellationToken ct = default)
733
+ {
734
+ page = Math.Max(1, page);
735
+ pageSize = Math.Clamp(pageSize, 1, MaxPageSize);
736
+
737
+ var totalCount = await query.CountAsync(ct);
738
+ var items = await query
739
+ .Skip((page - 1) * pageSize)
740
+ .Take(pageSize)
741
+ .ToListAsync(ct);
742
+
743
+ return new PaginatedResult<T>(items, totalCount, page, pageSize);
744
+ }
745
+ }
746
+ ```
747
+
748
+ ### Usage in Service (with search + extension method)
749
+
750
+ ```csharp
751
+ public async Task<PaginatedResult<{Name}ResponseDto>> GetAllAsync(
752
+ string? search = null, int page = 1, int pageSize = 20, CancellationToken ct = default)
753
+ {
754
+ var tenantId = _currentTenant.TenantId
755
+ ?? throw new TenantContextRequiredException();
756
+
757
+ var query = _db.{Name}s
758
+ .Where(x => x.TenantId == tenantId)
759
+ .AsNoTracking();
760
+
761
+ if (!string.IsNullOrWhiteSpace(search))
762
+ query = query.Where(x => x.Name.Contains(search) || x.Code.Contains(search));
763
+
764
+ return await query
765
+ .OrderBy(x => x.Name)
766
+ .Select(x => new {Name}ResponseDto(x.Id, x.Code, x.Name, x.CreatedAt))
767
+ .ToPaginatedResultAsync(page, pageSize, ct);
768
+ }
769
+ ```
770
+
771
+ ### Frontend Types (`@/types/pagination.ts`)
772
+
773
+ ```typescript
774
+ export interface PaginatedResult<T> {
775
+ items: T[];
776
+ totalCount: number;
777
+ page: number;
778
+ pageSize: number;
779
+ totalPages: number;
780
+ hasPreviousPage: boolean;
781
+ hasNextPage: boolean;
782
+ }
783
+
784
+ export interface PaginationParams {
785
+ page?: number;
786
+ pageSize?: number;
787
+ search?: string;
788
+ sortBy?: string;
789
+ sortDirection?: 'asc' | 'desc';
790
+ }
791
+ ```
792
+
793
+ ### FORBIDDEN Type Names
794
+
795
+ | Forbidden | Canonical Replacement |
796
+ |-----------|----------------------|
797
+ | `PagedResult<T>` | `PaginatedResult<T>` |
798
+ | `PaginatedResultDto<T>` | `PaginatedResult<T>` |
799
+ | `PaginatedResponse<T>` | `PaginatedResult<T>` |
800
+ | `PageResultDto<T>` | `PaginatedResult<T>` |
801
+ | `PaginatedRequest` | `PaginationParams` |
802
+ | `QueryParameters` | `PaginationParams` |
803
+ | `currentPage` (property) | `page` |
804
+ | `HasPrevious` (property) | `HasPreviousPage` |
805
+ | `HasNext` (property) | `HasNextPage` |
806
+
807
+ ### Rules
808
+
809
+ - **Max pageSize = 100** — enforced via `Math.Clamp(pageSize, 1, 100)` or extension method
810
+ - **Default page = 1, pageSize = 20** — all GetAll endpoints
811
+ - **Search param mandatory** — enables `EntityLookup` on frontend
812
+ - **POST-CHECK 16** blocks `List<T>` returns on GetAll
813
+ - **POST-CHECK 31** blocks non-canonical pagination type names
602
814
 
603
815
  ---
604
816
 
@@ -701,16 +913,28 @@ public class MyEntity : BaseEntity, ITenantEntity, IAuditableEntity
701
913
  **INCORRECT — Race condition:**
702
914
  ```csharp
703
915
  // WRONG: Two concurrent requests get the same count
704
- var count = await _db.MyEntities.Where(x => x.TenantId == _currentUser.TenantId).CountAsync(ct);
705
- return $"ENT{(count + 1):D4}";
916
+ var count = await _db.MyEntities.Where(x => x.TenantId == tenantId).CountAsync(ct);
917
+ return $"emp-{(count + 1):D5}";
706
918
  ```
707
919
 
708
- **CORRECT — Use `GenerateNextCodeAsync()` (atomic):**
920
+ **CORRECT — Use `ICodeGenerator<T>.NextCodeAsync()` (atomic with retry):**
709
921
  ```csharp
710
- var code = await GenerateNextCodeAsync("MyEntity", _currentUser.TenantId, ct);
922
+ private readonly ICodeGenerator<MyEntity> _codeGenerator;
923
+
924
+ // In CreateAsync:
925
+ var code = await _codeGenerator.NextCodeAsync(ct);
926
+ var entity = MyEntity.Create(tenantId, code, dto.Name, createdBy: null);
711
927
  ```
712
928
 
713
- **Why it's wrong:** `Count() + 1` causes **race conditions** — concurrent requests generate duplicate codes.
929
+ **Why it's wrong:** `Count() + 1` causes **race conditions** — concurrent requests generate duplicate codes. `ICodeGenerator<T>` uses `OrderByDescending` on existing codes + retry on unique constraint violation for safe concurrency.
930
+
931
+ **Key rules when using auto-generated codes:**
932
+ - **REMOVE** `Code` from `CreateDto` (auto-generated, not user-provided)
933
+ - **KEEP** `Code` in `ResponseDto` (returned to frontend)
934
+ - Register `ICodeGenerator<T>` in DI with `CodePatternConfig`
935
+ - Code regex in validators: `^[a-z0-9_-]+$` (supports hyphens)
936
+
937
+ **Full reference:** See `references/code-generation.md` for strategies (sequential, timestamp, yearly, UUID), volume-to-digits calculation, and complete implementation patterns.
714
938
 
715
939
  ---
716
940
 
@@ -223,6 +223,7 @@ import { useState, useCallback, useEffect } from 'react';
223
223
  import { useTranslation } from 'react-i18next';
224
224
  import { useNavigate, useParams } from 'react-router-dom';
225
225
  import { Loader2 } from 'lucide-react';
226
+ import { DocToggleButton } from '@/components/docs/DocToggleButton';
226
227
 
227
228
  // API hook (generated by scaffold_api_client)
228
229
  import { useEntityList } from '@/hooks/useEntity';
@@ -284,17 +285,20 @@ export function EntityListPage() {
284
285
  // 6. CONTENT — create button navigates to /create route
285
286
  return (
286
287
  <div className="space-y-6">
287
- {/* Header */}
288
+ {/* Header with DocToggleButton */}
288
289
  <div className="flex items-center justify-between">
289
290
  <h1 className="text-2xl font-bold text-[var(--text-primary)]">
290
291
  {t('{module}:title', 'Module Title')}
291
292
  </h1>
292
- <button
293
- onClick={() => navigate('create')}
294
- className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded"
295
- >
296
- {t('{module}:actions.create', 'Create')}
297
- </button>
293
+ <div className="flex items-center gap-2">
294
+ <DocToggleButton />
295
+ <button
296
+ onClick={() => navigate('create')}
297
+ className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded"
298
+ >
299
+ {t('{module}:actions.create', 'Create')}
300
+ </button>
301
+ </div>
298
302
  </div>
299
303
 
300
304
  {/* Content: SmartTable with row click → detail, edit action → /:id/edit */}
@@ -953,13 +957,20 @@ public async Task<PaginatedResult<EntityResponseDto>> GetAllAsync(
953
957
  ### Rules
954
958
 
955
959
  - **NEVER** render a `Guid` FK field as `<input type="text">` — always use `EntityLookup`
960
+ - **NEVER** render a `Guid` FK field as `<select>` — even with API-loaded `<option>` elements, `<select>` is NOT acceptable
956
961
  - **NEVER** ask the user to manually type or paste a GUID/ID
957
- - **ALWAYS** provide a search-based selection for FK fields
962
+ - **ALWAYS** provide a search-based selection via `<EntityLookup />` for FK fields
958
963
  - **ALWAYS** show the entity's display name (Name, FullName, Code+Name) not the GUID
959
964
  - **ALWAYS** include `mapOption` to define how the related entity is displayed
960
965
  - **ALWAYS** load the selected entity's display name on mount (for edit forms)
961
966
  - **ALWAYS** support clearing the selection (unless required + already set)
962
967
 
968
+ **Why `<select>` is NOT acceptable for FK fields:**
969
+ - `<select>` loads ALL options at once — fails with 100+ entities (performance + UX)
970
+ - `<select>` has no search/filter — user must scroll through all options
971
+ - `<select>` cannot show sublabels (code, department, etc.)
972
+ - `EntityLookup` provides: debounced API search, paginated results, display name resolution, sublabels
973
+
963
974
  **FORBIDDEN:**
964
975
  ```tsx
965
976
  // WRONG: Plain text input for FK field
@@ -970,6 +981,17 @@ public async Task<PaginatedResult<EntityResponseDto>> GetAllAsync(
970
981
  placeholder="Enter Employee ID..."
971
982
  />
972
983
 
984
+ // WRONG: <select> dropdown for FK field (even with API-loaded options)
985
+ <select
986
+ value={formData.departmentId}
987
+ onChange={(e) => setFormData({ ...formData, departmentId: e.target.value })}
988
+ >
989
+ <option value="">Select Department...</option>
990
+ {departments.map((dept) => (
991
+ <option key={dept.id} value={dept.id}>{dept.name}</option>
992
+ ))}
993
+ </select>
994
+
973
995
  // WRONG: Raw GUID displayed to user
974
996
  <span>{entity.departmentId}</span>
975
997
 
@@ -979,6 +1001,18 @@ public async Task<PaginatedResult<EntityResponseDto>> GetAllAsync(
979
1001
  </select>
980
1002
  ```
981
1003
 
1004
+ **CORRECT — ONLY this pattern:**
1005
+ ```tsx
1006
+ <EntityLookup
1007
+ apiEndpoint="/api/business/human-resources/departments"
1008
+ value={formData.departmentId}
1009
+ onChange={(id) => handleChange('departmentId', id)}
1010
+ label={t('module:form.department', 'Department')}
1011
+ mapOption={(dept) => ({ id: dept.id, label: dept.name, sublabel: dept.code })}
1012
+ required
1013
+ />
1014
+ ```
1015
+
982
1016
  ### I18n Keys for EntityLookup
983
1017
 
984
1018
  Add these keys to the module's translation files:
@@ -996,16 +1030,83 @@ Add these keys to the module's translation files:
996
1030
 
997
1031
  ---
998
1032
 
999
- ## 7. Checklist for /apex Frontend Execution
1033
+ ## 7. Documentation Panel Integration (DocToggleButton)
1034
+
1035
+ > **EVERY list/detail page MUST include a `DocToggleButton` in its header.**
1036
+ > This button opens the right-side documentation panel showing the module's user documentation.
1037
+
1038
+ ### Component Import
1039
+
1040
+ ```tsx
1041
+ import { DocToggleButton } from '@/components/docs/DocToggleButton';
1042
+ ```
1043
+
1044
+ ### Placement — Always in the page header actions area (top right)
1045
+
1046
+ ```tsx
1047
+ {/* Header with DocToggleButton */}
1048
+ <div className="flex items-center justify-between">
1049
+ <h1 className="text-2xl font-bold text-[var(--text-primary)]">
1050
+ {t('{module}:title', 'Module Title')}
1051
+ </h1>
1052
+ <div className="flex items-center gap-2">
1053
+ <DocToggleButton />
1054
+ <button onClick={() => navigate('create')} className="...">
1055
+ {t('{module}:actions.create', 'Create')}
1056
+ </button>
1057
+ </div>
1058
+ </div>
1059
+ ```
1060
+
1061
+ ### How it Works
1062
+
1063
+ 1. `DocToggleButton` uses `useDocPanel()` context (provided by the Layout)
1064
+ 2. On click → opens the `DocPanel` on the right side of the screen
1065
+ 3. The panel loads the module's documentation via iframe (`?embedded=true`)
1066
+ 4. Route → doc mapping is in `DocPanelContext.tsx` — maps current pathname to doc URL
1067
+ 5. Panel is resizable (20-60% width), size persists in localStorage
1068
+
1069
+ ### Documentation Generation
1070
+
1071
+ After frontend pages are created, invoke the `/documentation` skill to generate:
1072
+
1073
+ | File | Content |
1074
+ |------|---------|
1075
+ | `src/pages/docs/business/{app}/{module}/doc-data.ts` | Data-driven documentation (~50-80 lines) |
1076
+ | `src/pages/docs/business/{app}/{module}/index.tsx` | Page wrapper (~10 lines) using `DocRenderer` |
1077
+ | `src/i18n/locales/fr/docs-{app}-{module}.json` | French doc translations (source language) |
1078
+
1079
+ 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.
1080
+
1081
+ ### Custom Doc URL (optional)
1082
+
1083
+ If the automatic route mapping doesn't work for your module, pass a custom URL:
1084
+
1085
+ ```tsx
1086
+ <DocToggleButton customDocUrl="/docs/business/human-resources/employees" />
1087
+ ```
1088
+
1089
+ ### Rules
1090
+
1091
+ - **EVERY** list page MUST include `DocToggleButton` in its header actions
1092
+ - **EVERY** detail page MUST include `DocToggleButton` in its header actions
1093
+ - Create/Edit form pages do NOT need DocToggleButton (users don't read docs while filling forms)
1094
+ - DocToggleButton is imported from `@/components/docs/DocToggleButton` (shared component)
1095
+ - The Layout already provides `DocPanelProvider` — no additional wrapping needed
1096
+ - Documentation content is generated by the `/documentation` skill AFTER frontend pages exist
1097
+
1098
+ ---
1099
+
1100
+ ## 7b. Checklist for /apex Frontend Execution
1000
1101
 
1001
1102
  Before marking frontend tasks as complete, verify:
1002
1103
 
1003
1104
  - [ ] All page imports use `React.lazy()` with named export wrapping
1004
1105
  - [ ] `<Suspense fallback={<PageLoader />}>` wraps all lazy components in routes
1005
- - [ ] Translation files exist for **all 4 languages** (fr, en, it, de)
1106
+ - [ ] Translation files exist for **all 4 languages** (fr, en, it, de) in `src/i18n/locales/`
1006
1107
  - [ ] All `t()` calls include namespace prefix AND fallback value
1007
1108
  - [ ] No hardcoded strings in JSX — all text goes through `t()`
1008
- - [ ] CSS uses variables only — no hardcoded Tailwind colors
1109
+ - [ ] CSS uses variables only — no hardcoded Tailwind colors (BLOCKING POST-CHECK 13)
1009
1110
  - [ ] Pages follow loading → error → content pattern
1010
1111
  - [ ] Pages use `src/pages/{Context}/{App}/{Module}/` hierarchy
1011
1112
  - [ ] API calls use generated hooks or `apiClient` (never raw axios)
@@ -1019,6 +1120,262 @@ Before marking frontend tasks as complete, verify:
1019
1120
  - [ ] No `<Modal>`, `<Dialog>`, `<Drawer>` imports in form-related pages
1020
1121
  - [ ] Form pages include back button with `navigate(-1)`
1021
1122
  - [ ] Form pages are covered by frontend tests (see section 8)
1123
+ - [ ] **`DocToggleButton` present in header of every list/detail page (see section 7)**
1124
+ - [ ] **`/documentation` skill invoked to generate module doc-data.ts**
1125
+
1126
+ ---
1127
+
1128
+ ## 7c. Cross-Tenant Entity UI Patterns
1129
+
1130
+ > **For optional and scoped tenant entities, the frontend MUST provide UI controls to set the scope/visibility.**
1131
+
1132
+ ### Scope Types
1133
+
1134
+ | Type | Behavior | Use case |
1135
+ |------|----------|----------|
1136
+ | **Optional** | Entity can be tenant-specific OR shared (binary choice) | Data that can belong to one org or all orgs |
1137
+ | **Scoped** | Entity has explicit scope enum: Tenant / Shared / Platform | Data with multiple visibility levels |
1138
+
1139
+ ### Scope Selector in Create Forms (Optional Entities)
1140
+
1141
+ For `optional` tenant entities, add a toggle in the create form allowing the user to decide:
1142
+
1143
+ ```tsx
1144
+ import { useState } from 'react';
1145
+ import { useTranslation } from 'react-i18next';
1146
+
1147
+ export function EntityCreatePage() {
1148
+ const { t } = useTranslation(['{module}']);
1149
+ const [formData, setFormData] = useState({
1150
+ name: '',
1151
+ isShared: false, // User decision: tenant-specific (false) or shared (true)
1152
+ });
1153
+
1154
+ const handleScopeChange = (value: string) => {
1155
+ setFormData({ ...formData, isShared: value === 'shared' });
1156
+ };
1157
+
1158
+ return (
1159
+ <div className="space-y-6">
1160
+ {/* ... form header ... */}
1161
+
1162
+ <SmartForm fields={[
1163
+ {
1164
+ name: 'name',
1165
+ type: 'text',
1166
+ label: t('{module}:form.name', 'Name'),
1167
+ required: true,
1168
+ },
1169
+ // Scope selector — binary toggle for optional entities
1170
+ {
1171
+ name: 'scope',
1172
+ type: 'custom',
1173
+ label: t('common:scope', 'Scope'),
1174
+ render: () => (
1175
+ <div className="space-y-2">
1176
+ <label className="block text-sm font-medium text-[var(--text-primary)]">
1177
+ {t('common:scope', 'Scope')}
1178
+ </label>
1179
+ <select
1180
+ value={formData.isShared ? 'shared' : 'tenant'}
1181
+ onChange={(e) => handleScopeChange(e.target.value)}
1182
+ className="w-full px-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)] text-[var(--text-primary)]"
1183
+ >
1184
+ <option value="tenant">
1185
+ {t('common:scope.tenant', 'My Organization')}
1186
+ </option>
1187
+ <option value="shared">
1188
+ {t('common:scope.shared', 'Shared (All Organizations)')}
1189
+ </option>
1190
+ </select>
1191
+ <p className="text-xs text-[var(--text-secondary)]">
1192
+ {formData.isShared
1193
+ ? t('common:scope.shared.hint', 'This data will be accessible to all organizations')
1194
+ : t('common:scope.tenant.hint', 'This data will only be visible to your organization')}
1195
+ </p>
1196
+ </div>
1197
+ ),
1198
+ },
1199
+ ]} />
1200
+ </div>
1201
+ );
1202
+ }
1203
+ ```
1204
+
1205
+ ### Scope Selector in Create Forms (Scoped Entities)
1206
+
1207
+ For `scoped` entities with explicit enum values (Tenant, Shared, Platform), use a dropdown with all scope options:
1208
+
1209
+ ```tsx
1210
+ export function EntityCreatePage() {
1211
+ const { t } = useTranslation(['{module}']);
1212
+ const [formData, setFormData] = useState({
1213
+ name: '',
1214
+ scope: 'Tenant', // Enum: 'Tenant' | 'Shared' | 'Platform'
1215
+ });
1216
+
1217
+ return (
1218
+ <SmartForm fields={[
1219
+ {
1220
+ name: 'name',
1221
+ type: 'text',
1222
+ label: t('{module}:form.name', 'Name'),
1223
+ required: true,
1224
+ },
1225
+ {
1226
+ name: 'scope',
1227
+ type: 'select',
1228
+ label: t('common:scope', 'Scope'),
1229
+ options: [
1230
+ { value: 'Tenant', label: t('common:scope.tenant', 'My Organization') },
1231
+ { value: 'Shared', label: t('common:scope.shared', 'Shared') },
1232
+ { value: 'Platform', label: t('common:scope.platform', 'Platform (Admin Only)') },
1233
+ ],
1234
+ default: 'Tenant',
1235
+ required: true,
1236
+ help: t('common:scope.help', 'Select the visibility level for this data'),
1237
+ },
1238
+ ]} />
1239
+ );
1240
+ }
1241
+ ```
1242
+
1243
+ ### Scope Indicator in List Views
1244
+
1245
+ Display a visual indicator/badge on each row showing the entity scope:
1246
+
1247
+ ```tsx
1248
+ import { useTranslation } from 'react-i18next';
1249
+
1250
+ // ScopeBadge component for reuse
1251
+ interface ScopeBadgeProps {
1252
+ tenantId?: string | null; // For optional entities: null = shared, value = tenant-specific
1253
+ scope?: string; // For scoped entities: 'Tenant' | 'Shared' | 'Platform'
1254
+ }
1255
+
1256
+ export function ScopeBadge({ tenantId, scope }: ScopeBadgeProps) {
1257
+ const { t } = useTranslation(['common']);
1258
+
1259
+ // Optional entity scope
1260
+ if (tenantId !== undefined) {
1261
+ const isTenant = Boolean(tenantId);
1262
+ return (
1263
+ <span
1264
+ className={`px-2 py-1 rounded-full text-xs font-semibold ${
1265
+ isTenant
1266
+ ? 'bg-[var(--bg-accent-light)] text-[var(--color-accent-600)]'
1267
+ : 'bg-[var(--bg-secondary)] text-[var(--text-secondary)]'
1268
+ }`}
1269
+ >
1270
+ {isTenant
1271
+ ? t('common:scope.tenant', 'Tenant')
1272
+ : t('common:scope.shared', 'Shared')}
1273
+ </span>
1274
+ );
1275
+ }
1276
+
1277
+ // Scoped entity scope
1278
+ if (scope) {
1279
+ const scopeStyles: Record<string, { bg: string; text: string }> = {
1280
+ Tenant: {
1281
+ bg: 'bg-[var(--bg-accent-light)]',
1282
+ text: 'text-[var(--color-accent-600)]',
1283
+ },
1284
+ Shared: {
1285
+ bg: 'bg-[var(--bg-secondary)]',
1286
+ text: 'text-[var(--text-secondary)]',
1287
+ },
1288
+ Platform: {
1289
+ bg: 'bg-[var(--bg-warning-light)]',
1290
+ text: 'text-[var(--color-warning-600)]',
1291
+ },
1292
+ };
1293
+
1294
+ const style = scopeStyles[scope] || scopeStyles.Tenant;
1295
+ const scopeLabel = {
1296
+ Tenant: t('common:scope.tenant', 'Organization'),
1297
+ Shared: t('common:scope.shared', 'Shared'),
1298
+ Platform: t('common:scope.platform', 'Platform'),
1299
+ }[scope] || scope;
1300
+
1301
+ return (
1302
+ <span className={`px-2 py-1 rounded-full text-xs font-semibold ${style.bg} ${style.text}`}>
1303
+ {scopeLabel}
1304
+ </span>
1305
+ );
1306
+ }
1307
+
1308
+ return null;
1309
+ }
1310
+ ```
1311
+
1312
+ ### Using ScopeBadge in SmartTable Columns
1313
+
1314
+ ```tsx
1315
+ // In the list page, add a scope column
1316
+ const columns = [
1317
+ { key: 'name', label: t('{module}:columns.name', 'Name') },
1318
+ { key: 'code', label: t('{module}:columns.code', 'Code') },
1319
+ {
1320
+ key: 'scope',
1321
+ label: t('common:scope', 'Scope'),
1322
+ render: (row) => (
1323
+ // For optional entities: show based on tenantId
1324
+ <ScopeBadge tenantId={row.tenantId} />
1325
+ // OR for scoped entities: show based on scope field
1326
+ // <ScopeBadge scope={row.scope} />
1327
+ ),
1328
+ },
1329
+ { key: 'actions', label: t('{module}:columns.actions', 'Actions') },
1330
+ ];
1331
+
1332
+ return (
1333
+ <SmartTable
1334
+ columns={columns}
1335
+ data={data}
1336
+ loading={loading}
1337
+ onRowClick={(row) => navigate(`${row.id}`)}
1338
+ />
1339
+ );
1340
+ ```
1341
+
1342
+ ### I18n Keys for Scope UI
1343
+
1344
+ Add these keys to `src/i18n/locales/*/common.json`:
1345
+
1346
+ ```json
1347
+ {
1348
+ "scope": "Scope",
1349
+ "scope.tenant": "My Organization",
1350
+ "scope.tenant.hint": "This data will only be visible to your organization",
1351
+ "scope.shared": "Shared (All Organizations)",
1352
+ "scope.shared.hint": "This data will be accessible to all organizations",
1353
+ "scope.platform": "Platform (Admin Only)",
1354
+ "scope.help": "Select the visibility level for this data"
1355
+ }
1356
+ ```
1357
+
1358
+ And in the module-specific translation files (e.g., `employees.json`):
1359
+
1360
+ ```json
1361
+ {
1362
+ "form": {
1363
+ "scope": "Scope",
1364
+ "scopeHint": "Choose who can see this data"
1365
+ }
1366
+ }
1367
+ ```
1368
+
1369
+ ### Rules
1370
+
1371
+ - **ALWAYS** provide scope controls in create forms for optional/scoped entities
1372
+ - **ALWAYS** show scope indicator badges in list views
1373
+ - **ALWAYS** use `ScopeBadge` component for consistency across modules
1374
+ - **NEVER** let users create shared entities without explicit choice
1375
+ - **NEVER** hide scope controls — scope is a business-critical property
1376
+ - **ALWAYS** include scope-related translation keys in i18n files (all 4 languages)
1377
+ - **FORBIDDEN:** Form field for scope labeled ambiguously (e.g., "Public/Private" without context)
1378
+ - **FORBIDDEN:** Scope badges with hardcoded colors — always use CSS variables
1022
1379
 
1023
1380
  ---
1024
1381