@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.
- package/.documentation/installation.html +7 -2
- package/README.md +7 -1
- package/dist/index.js +33 -37
- package/dist/index.js.map +1 -1
- package/dist/mcp-entry.mjs +547 -97
- package/dist/mcp-entry.mjs.map +1 -1
- package/package.json +1 -1
- package/scripts/health-check.sh +2 -1
- package/templates/mcp-scaffolding/controller.cs.hbs +10 -7
- package/templates/mcp-scaffolding/entity-extension.cs.hbs +132 -124
- package/templates/mcp-scaffolding/frontend/api-client.ts.hbs +4 -4
- package/templates/mcp-scaffolding/tests/controller.test.cs.hbs +38 -15
- package/templates/mcp-scaffolding/tests/service.test.cs.hbs +20 -8
- package/templates/skills/apex/SKILL.md +7 -9
- package/templates/skills/apex/_shared.md +9 -2
- package/templates/skills/apex/references/code-generation.md +412 -0
- package/templates/skills/apex/references/post-checks.md +377 -37
- package/templates/skills/apex/references/smartstack-api.md +229 -5
- package/templates/skills/apex/references/smartstack-frontend.md +368 -11
- package/templates/skills/apex/references/smartstack-layers.md +54 -7
- package/templates/skills/apex/steps/step-00-init.md +1 -2
- package/templates/skills/apex/steps/step-01-analyze.md +45 -2
- package/templates/skills/apex/steps/step-02-plan.md +23 -2
- package/templates/skills/apex/steps/step-03-execute.md +195 -5
- package/templates/skills/apex/steps/step-04-examine.md +18 -5
- package/templates/skills/apex/steps/step-05-deep-review.md +9 -11
- package/templates/skills/apex/steps/step-06-resolve.md +5 -9
- package/templates/skills/apex/steps/step-07-tests.md +66 -1
- package/templates/skills/apex/steps/step-08-run-tests.md +12 -3
- package/templates/skills/application/references/provider-template.md +62 -39
- package/templates/skills/application/templates-backend.md +3 -3
- package/templates/skills/application/templates-frontend.md +12 -12
- package/templates/skills/application/templates-seed.md +14 -4
- package/templates/skills/business-analyse/SKILL.md +10 -7
- package/templates/skills/business-analyse/questionnaire/04-data.md +8 -0
- package/templates/skills/business-analyse/references/agent-module-prompt.md +84 -5
- package/templates/skills/business-analyse/references/agent-pooling-best-practices.md +83 -19
- package/templates/skills/business-analyse/references/consolidation-structural-checks.md +6 -2
- package/templates/skills/business-analyse/references/team-orchestration.md +470 -113
- package/templates/skills/business-analyse/references/validation-checklist.md +5 -4
- package/templates/skills/business-analyse/schemas/sections/analysis-schema.json +44 -0
- package/templates/skills/business-analyse/steps/step-03a2-analysis.md +72 -1
- package/templates/skills/business-analyse/steps/step-03c-compile.md +93 -7
- package/templates/skills/business-analyse/steps/step-03d-validate.md +34 -2
- package/templates/skills/business-analyse/steps/step-04b-analyze.md +40 -0
- package/templates/skills/controller/references/controller-code-templates.md +2 -2
- package/templates/skills/controller/templates.md +12 -12
- package/templates/skills/feature-full/steps/step-01-implementation.md +4 -4
- package/templates/skills/ralph-loop/references/category-rules.md +44 -2
- package/templates/skills/ralph-loop/references/compact-loop.md +37 -0
- package/templates/skills/ralph-loop/references/core-seed-data.md +51 -20
- 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 ==
|
|
705
|
-
return $"
|
|
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 `
|
|
920
|
+
**CORRECT — Use `ICodeGenerator<T>.NextCodeAsync()` (atomic with retry):**
|
|
709
921
|
```csharp
|
|
710
|
-
|
|
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
|
-
<
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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.
|
|
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
|
|