@atlashub/smartstack-cli 4.31.0 → 4.33.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 (41) hide show
  1. package/.documentation/commands.html +952 -116
  2. package/.documentation/index.html +2 -2
  3. package/.documentation/init.html +358 -174
  4. package/dist/mcp-entry.mjs +271 -44
  5. package/dist/mcp-entry.mjs.map +1 -1
  6. package/package.json +1 -1
  7. package/templates/mcp-scaffolding/controller.cs.hbs +54 -128
  8. package/templates/project/README.md +19 -0
  9. package/templates/skills/apex/SKILL.md +16 -10
  10. package/templates/skills/apex/_shared.md +1 -1
  11. package/templates/skills/apex/references/checks/architecture-checks.sh +154 -0
  12. package/templates/skills/apex/references/checks/backend-checks.sh +194 -0
  13. package/templates/skills/apex/references/checks/frontend-checks.sh +448 -0
  14. package/templates/skills/apex/references/checks/infrastructure-checks.sh +255 -0
  15. package/templates/skills/apex/references/checks/security-checks.sh +153 -0
  16. package/templates/skills/apex/references/checks/seed-checks.sh +536 -0
  17. package/templates/skills/apex/references/frontend-route-wiring-app-tsx.md +49 -192
  18. package/templates/skills/apex/references/parallel-execution.md +18 -5
  19. package/templates/skills/apex/references/post-checks.md +124 -2156
  20. package/templates/skills/apex/references/smartstack-api.md +160 -957
  21. package/templates/skills/apex/references/smartstack-frontend-compliance.md +23 -1
  22. package/templates/skills/apex/references/smartstack-frontend.md +134 -1022
  23. package/templates/skills/apex/references/smartstack-layers.md +12 -6
  24. package/templates/skills/apex/steps/step-00-init.md +81 -238
  25. package/templates/skills/apex/steps/step-03-execute.md +25 -751
  26. package/templates/skills/apex/steps/step-03a-layer0-domain.md +118 -0
  27. package/templates/skills/apex/steps/step-03b-layer1-seed.md +91 -0
  28. package/templates/skills/apex/steps/step-03c-layer2-backend.md +240 -0
  29. package/templates/skills/apex/steps/step-03d-layer3-frontend.md +300 -0
  30. package/templates/skills/apex/steps/step-03e-layer4-devdata.md +44 -0
  31. package/templates/skills/apex/steps/step-04-examine.md +70 -150
  32. package/templates/skills/application/references/frontend-i18n-and-output.md +2 -2
  33. package/templates/skills/application/references/frontend-route-naming.md +5 -1
  34. package/templates/skills/application/references/frontend-route-wiring-app-tsx.md +49 -198
  35. package/templates/skills/application/references/frontend-verification.md +11 -11
  36. package/templates/skills/application/steps/step-05-frontend.md +26 -15
  37. package/templates/skills/application/templates-frontend.md +4 -0
  38. package/templates/skills/cli-app-sync/SKILL.md +2 -2
  39. package/templates/skills/cli-app-sync/references/comparison-map.md +1 -1
  40. package/templates/skills/controller/references/controller-code-templates.md +70 -67
  41. package/templates/skills/controller/references/mcp-scaffold-workflow.md +5 -1
@@ -1,16 +1,14 @@
1
1
  # SmartStack Frontend Patterns — Mandatory Reference
2
2
 
3
- > **Loaded by:** step-03 (execution) and step-04 (validation)
4
- > **Purpose:** Defines mandatory frontend patterns extracted from SmartStack.app.
3
+ > **Loaded by:** step-03d (Layer 3, deferred) and step-04 (validation)
4
+ > **Purpose:** Mandatory frontend conventions. `/ui-components` generates pages — this file provides guard rails.
5
5
  > **Enforcement:** POST-CHECKs in step-04 verify compliance.
6
6
 
7
7
  ---
8
8
 
9
9
  ## 1. Lazy Loading (React.lazy + Suspense)
10
10
 
11
- > **ALL page components MUST be lazy-loaded.** Only critical entry pages (HomePage, LoginPage) may use static imports.
12
-
13
- ### Import Pattern
11
+ ALL page components MUST be lazy-loaded. Only critical entry pages (HomePage, LoginPage) may use static imports.
14
12
 
15
13
  ```tsx
16
14
  // Named exports — use .then() to wrap
@@ -20,17 +18,7 @@ const EmployeesPage = lazy(() =>
20
18
  .then(m => ({ default: m.EmployeesPage }))
21
19
  );
22
20
 
23
- // Default exports direct lazy
24
- const DashboardPage = lazy(() => import('@/pages/Platform/Admin/DashboardPage'));
25
- ```
26
-
27
- ### Suspense Wrapper
28
-
29
- ```tsx
30
- import { Suspense } from 'react';
31
- import { PageLoader } from '@/components/ui/PageLoader';
32
-
33
- // Route element wrapping
21
+ // Suspense wrapper with PermissionGuard
34
22
  element: (
35
23
  <Suspense fallback={<PageLoader />}>
36
24
  <PermissionGuard permissions={ROUTES['hr.employees'].permissions}>
@@ -40,74 +28,27 @@ element: (
40
28
  )
41
29
  ```
42
30
 
43
- ### Rules
44
-
31
+ **Rules:**
45
32
  - Do not static-import page components in route files
46
33
  - Use `<Suspense fallback={<PageLoader />}>` around lazy components
47
- - Use the `.then(m => ({ default: m.ComponentName }))` pattern for named exports
48
- - The unified AppLayout component is ALSO lazy-loaded
49
-
50
- **Incorrect patterns:**
51
- ```tsx
52
- // WRONG: static import in route file
53
- import { EmployeesPage } from '@/pages/HumanResources/EmployeeManagement/Employees/EmployeesPage';
54
-
55
- // WRONG: no Suspense wrapper
56
- element: <EmployeesPage />
57
-
58
- // WRONG: no fallback
59
- <Suspense><EmployeesPage /></Suspense>
60
- ```
61
-
62
- ### Client App.tsx — Lazy Imports Required
63
-
64
- In the client `App.tsx` (where application routes are defined), all page imports must use `React.lazy()`.
65
-
66
- **Correct — Lazy imports in client App.tsx:**
67
- ```tsx
68
- // Path includes module level: {App}/{Module}/{Section}/{Page}
69
- const ClientsListPage = lazy(() =>
70
- import('@/pages/HumanResources/ClientManagement/Clients/ClientsListPage')
71
- .then(m => ({ default: m.ClientsListPage }))
72
- );
73
- ```
74
-
75
- **Do not use — Static imports in client App.tsx:**
76
- ```tsx
77
- // WRONG: Static import kills code splitting
78
- import { ClientsListPage } from '@/pages/HumanResources/ClientManagement/Clients/ClientsListPage';
79
- ```
80
-
81
- > **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.
34
+ - Use `.then(m => ({ default: m.ComponentName }))` for named exports
35
+ - Unified AppLayout is also lazy-loaded
36
+ - Client `App.tsx` MUST always use lazy imports for business pages (static imports kill code splitting)
82
37
 
83
38
  ---
84
39
 
85
40
  ## 2. I18n / Translations (react-i18next)
86
41
 
87
- > **ALL user-facing text MUST use translations.** 4 languages required: fr, en, it, de.
42
+ ALL user-facing text MUST use translations. 4 languages required: fr, en, it, de.
88
43
 
89
44
  ### File Structure
90
45
 
91
46
  ```
92
- src/i18n/
93
- ├── config.ts # i18n initialization
94
- ├── locales/
95
- │ ├── fr/
96
- │ │ ├── common.json # Shared keys (actions, errors, validation)
97
- │ │ ├── navigation.json # Menu labels
98
- │ │ └── {module}.json # Module-specific keys
99
- │ ├── en/
100
- │ │ └── {module}.json
101
- │ ├── it/
102
- │ │ └── {module}.json
103
- │ └── de/
104
- │ └── {module}.json
47
+ src/i18n/locales/{lang}/{module}.json # Per-module translation file (4 languages)
105
48
  ```
106
49
 
107
50
  ### Module JSON Template
108
51
 
109
- Each new module MUST generate a translation file with this structure:
110
-
111
52
  ```json
112
53
  {
113
54
  "title": "Module display name",
@@ -171,600 +112,111 @@ Each new module MUST generate a translation file with this structure:
171
112
  }
172
113
  ```
173
114
 
174
- ### Usage in Components
115
+ ### Usage
175
116
 
176
117
  ```tsx
177
- // Hook — specify namespace(s)
178
118
  const { t } = useTranslation(['employees']);
179
-
180
- // Simple key provide fallback value
181
- t('employees:title', 'Employees')
182
-
183
- // Key with interpolation
184
- t('employees:messages.created', '{{entity}} created successfully', { entity: 'Employee' })
185
-
186
- // Namespace prefix syntax
187
- t('employees:actions.create', 'Create employee')
188
- t('common:actions.save', 'Save')
189
- t('common:errors.network', 'Network error')
119
+ t('employees:title', 'Employees') // Simple key + fallback
120
+ t('employees:messages.created', '{{entity}} created', { entity: 'Employee' }) // Interpolation
121
+ t('common:actions.save', 'Save') // Cross-namespace
190
122
  ```
191
123
 
192
- ### Namespace Registration
193
-
194
- After creating i18n JSON files, register each namespace in the i18n config.
195
- Root cause (test-apex-007): JSON files existed but namespaces were not registered → `useTranslation(['module'])` returned empty strings.
124
+ ### Namespace Registration (CRITICAL — POST-CHECK C39)
196
125
 
197
- In the i18n config file (`src/i18n/config.ts` or `src/i18n/index.ts`), add each new namespace:
126
+ After creating i18n JSON files, register each namespace in `src/i18n/config.ts`:
198
127
 
199
128
  ```typescript
200
- // Example: registering new module namespaces
201
129
  import employees from './locales/fr/employees.json';
202
- import projects from './locales/fr/projects.json';
203
- import clients from './locales/fr/clients.json';
204
-
205
- // In resources configuration:
206
- resources: {
207
- fr: { employees, projects, clients, common, navigation },
208
- en: { employees: employeesEn, projects: projectsEn, clients: clientsEn, ... },
209
- // ... it, de
210
- }
211
-
212
- // OR with ns array:
213
- ns: ['common', 'navigation', 'employees', 'projects', 'clients'],
130
+ // In resources: fr: { employees, common, navigation, ... }
131
+ // OR in ns array: ns: ['common', 'navigation', 'employees'],
214
132
  ```
215
133
 
216
- POST-CHECK C39 validates this. Unregistered namespaces must be registered.
217
-
218
- ### Rules
134
+ Root cause (test-apex-007): JSON files existed but namespaces were not registered → `useTranslation(['module'])` returned empty strings.
219
135
 
220
- - Provide a fallback value as 2nd argument to `t()`
221
- - Use namespace prefix: `t('namespace:key')`
136
+ **Rules:**
137
+ - Provide fallback value as 2nd argument to `t()`
138
+ - Use namespace prefix: `t('namespace:key')` — never `t('key')` without namespace
222
139
  - Generate 4 language files (fr, en, it, de) with identical key structures
223
140
  - Register new namespaces in i18n config file after creating JSON files
224
141
  - Do not hardcode user-facing strings in JSX
225
- - Do not use `t('key')` without namespace prefix
226
-
227
- **Incorrect patterns:**
228
- ```tsx
229
- // WRONG: no fallback
230
- t('employees:title')
231
-
232
- // WRONG: no namespace
233
- t('title')
234
-
235
- // WRONG: hardcoded text
236
- <h1>Employees</h1>
237
-
238
- // WRONG: only 2 languages generated
239
- // Must have fr, en, it, de
240
- ```
241
142
 
242
143
  ---
243
144
 
244
- ## 3. Page Structure Pattern
145
+ ## 3. Page Structure
245
146
 
246
- > **ALL pages MUST follow this structure.** Extracted from SmartStack.app reference implementation.
147
+ > `/ui-components` generates complete pages. These rules ensure compliance.
247
148
 
248
- ### Standard List Page Template
149
+ ### Standard List Page Structure
249
150
 
250
- ```tsx
251
- import { useState, useCallback, useEffect } from 'react';
252
- import { useTranslation } from 'react-i18next';
253
- import { useNavigate, useParams } from 'react-router-dom';
254
- import { Loader2 } from 'lucide-react';
255
- import { DocToggleButton } from '@/components/docs/DocToggleButton';
256
- import { DataTable } from '@/components/ui/DataTable';
257
-
258
- // API hook (generated by scaffold_api_client)
259
- import { useEntityList } from '@/hooks/useEntity';
260
-
261
- export function EntityListPage() {
262
- // 1. HOOKS — always at the top
263
- const { t } = useTranslation(['{module}']);
264
- const navigate = useNavigate();
265
-
266
- // 2. STATE
267
- const [loading, setLoading] = useState(true);
268
- const [error, setError] = useState<string | null>(null);
269
- const [data, setData] = useState<Entity[]>([]);
270
-
271
- // 3. DATA LOADING (useCallback + useEffect)
272
- const loadData = useCallback(async () => {
273
- try {
274
- setLoading(true);
275
- setError(null);
276
- const result = await entityApi.getAll();
277
- setData(result?.items ?? []);
278
- } catch (err: any) {
279
- setError(err.message || t('{module}:errors.loadFailed', 'Failed to load data'));
280
- } finally {
281
- setLoading(false);
282
- }
283
- }, [t]);
284
-
285
- useEffect(() => {
286
- loadData();
287
- }, [loadData]);
288
-
289
- // 4. LOADING STATE
290
- if (loading) {
291
- return (
292
- <div className="flex items-center justify-center min-h-[400px]">
293
- <Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
294
- </div>
295
- );
296
- }
297
-
298
- // 5. ERROR STATE
299
- if (error) {
300
- return (
301
- <div className="flex items-center justify-center min-h-[400px]">
302
- <div className="text-center">
303
- <p className="text-[var(--text-secondary)]">{error}</p>
304
- <button
305
- onClick={loadData}
306
- className="mt-4 px-4 py-2 bg-[var(--color-accent-500)] text-white rounded"
307
- >
308
- {t('common:actions.retry', 'Retry')}
309
- </button>
310
- </div>
311
- </div>
312
- );
313
- }
314
-
315
- // 6. CONTENT — create button navigates to /create route
316
- return (
317
- <div className="space-y-6">
318
- {/* Header with DocToggleButton */}
319
- <div className="flex items-center justify-between">
320
- <h1 className="text-2xl font-bold text-[var(--text-primary)]">
321
- {t('{module}:title', 'Module Title')}
322
- </h1>
323
- <div className="flex items-center gap-2">
324
- <DocToggleButton />
325
- <button
326
- onClick={() => navigate('create')}
327
- className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded"
328
- >
329
- {t('{module}:actions.create', 'Create')}
330
- </button>
331
- </div>
332
- </div>
333
-
334
- {/* Content: DataTable with row click → detail */}
335
- {data.length === 0 ? (
336
- <div className="text-center py-12 text-[var(--text-secondary)]">
337
- {t('{module}:empty', 'No items found.')}
338
- </div>
339
- ) : (
340
- <DataTable
341
- data={data}
342
- columns={[
343
- { key: 'name', label: t('{module}:columns.name', 'Name'), sortable: true },
344
- { key: 'code', label: t('{module}:columns.code', 'Code'), sortable: true },
345
- { key: 'status', label: t('{module}:columns.status', 'Status'),
346
- render: (item) => (
347
- <span className={`px-2 py-0.5 rounded text-xs ${
348
- item.isActive
349
- ? 'bg-[var(--success-bg)] text-[var(--success-text)]'
350
- : 'bg-[var(--error-bg)] text-[var(--error-text)]'
351
- }`}>
352
- {item.isActive ? t('common:status.active', 'Active') : t('common:status.inactive', 'Inactive')}
353
- </span>
354
- )
355
- },
356
- ]}
357
- searchable
358
- pagination={{ pageSize: 10 }}
359
- onRowClick={(item) => navigate(`${item.id}`)}
360
- />
361
- )}
362
- </div>
363
- );
364
- }
365
- ```
366
-
367
- > **DEFENSIVE RULE — API Response Guards:**
368
- > Always use `result?.items ?? []` when extracting arrays from API responses.
369
- > SmartStack's axios interceptor may resolve with `undefined` on auth failures (401/403)
370
- > instead of rejecting the promise. Without the guard, `data.length` will crash.
371
-
372
- ### Detail Page Pattern
373
-
374
- ```tsx
375
- export function EntityDetailPage() {
376
- const { entityId } = useParams<{ entityId: string }>();
377
- const { t } = useTranslation(['{module}']);
378
- const navigate = useNavigate();
379
-
380
- const [entity, setEntity] = useState<Entity | null>(null);
381
- const [loading, setLoading] = useState(true);
382
- const [activeTab, setActiveTab] = useState('info');
383
-
384
- // Lazy tab loading — load data only when tab is first visited
385
- const visitedTabsRef = useRef<Set<string>>(new Set(['info']));
386
-
387
- useEffect(() => {
388
- if (!visitedTabsRef.current.has(activeTab)) {
389
- visitedTabsRef.current.add(activeTab);
390
- // Load tab-specific data here (e.g., fetch leaves for this employee)
391
- }
392
- }, [activeTab]);
393
-
394
- // Edit button navigates to /:id/edit route (not modal)
395
- const handleEdit = () => navigate(`edit`);
396
-
397
- // ... loading/error/content pattern
398
- }
399
- ```
400
-
401
- ### Tab Behavior Rules
402
-
403
- Tabs on detail pages switch content locally, not by navigating to other pages.
404
- Each tab renders its content inline within the same page component.
405
- Sub-resource data (e.g., an employee's leaves) loads via API call filtered by the parent entity ID.
406
-
407
- **Tab state management:**
408
- - Tabs use `useState<TabKey>('info')` for the active tab — local React state only
409
- - Tab click handler: `onClick={() => setActiveTab(tabKey)}` — do not use `navigate()`
410
- - Tab content: conditional rendering `{activeTab === 'tabKey' && <TabContent />}`
411
- - Lazy loading: `visitedTabsRef` tracks which tabs have been visited to avoid redundant API calls
151
+ 1. **HOOKS** — `useTranslation`, `useNavigate` at the top
152
+ 2. **STATE** `loading`, `error`, `data` via `useState`
153
+ 3. **DATA LOADING** `useCallback` + `useEffect` pattern
154
+ 4. **LOADING STATE** `Loader2` spinner centered (`min-h-[400px]`)
155
+ 5. **ERROR STATE** error message + retry button
156
+ 6. **CONTENT** — header (title + `DocToggleButton` + create button) + `DataTable` or empty state
412
157
 
413
- **Tab content for sub-resources:**
414
- ```tsx
415
- // CORRECT — sub-resource data loaded INLINE within the tab
416
- {activeTab === 'leaves' && (
417
- <div>
418
- <LeaveRequestsTable employeeId={entity.id} />
419
- {/* Optional "View all" link INSIDE the tab content area */}
420
- <Link to={`../leaves?employee=${entity.id}`}>
421
- {t('employees:tabs.viewAllLeaves', 'View all leave requests')}
422
- </Link>
423
- </div>
424
- )}
425
- ```
426
-
427
- **Incorrect tab patterns:**
428
- ```tsx
429
- // Incorrect — tab click handler navigates to another page
430
- const handleTabClick = (tab: TabKey) => {
431
- setActiveTab(tab);
432
- if (tab === 'leaves') navigate(`../leaves?employee=${id}`); // ← breaks tab UX
433
- };
158
+ **API response guard (MANDATORY):** Always use `result?.items ?? []` when extracting arrays.
159
+ SmartStack's axios interceptor may resolve with `undefined` on auth failures (401/403) instead of rejecting. Without the guard, `data.length` crashes.
434
160
 
435
- // Incorrect tab content is empty because navigation already left the page
436
- {activeTab === 'info' && <div>...</div>}
437
- // Leaves tab: nothing renders here, user is already on another page
438
- ```
439
-
440
- **Why this matters:**
441
- - Navigating away loses the detail page context (entity data, scroll position, other tab state)
442
- - Users expect tabs to switch content in-place, not redirect to a different page
443
- - The browser back button should go to the list page, not toggle between tabs
161
+ ### Detail Page Rules
444
162
 
445
- **POST-CHECK C37 enforces this rule.**
163
+ - Edit button: `navigate('edit')` → separate edit page, not modal
164
+ - Tabs: local `useState<TabKey>('info')` — do NOT `navigate()` on tab click
165
+ - Tab content: inline conditional `{activeTab === 'tab' && <Content />}`
166
+ - Lazy tab loading: `visitedTabsRef` tracks visited tabs to avoid redundant API calls
167
+ - Sub-resource tabs load data filtered by parent entity ID inline
168
+ - POST-CHECK C37 enforces: tabs switch content in-place, not redirect to other pages
446
169
 
447
170
  ---
448
171
 
449
- ## 3b. Form Pages Pattern (Create / Edit)
172
+ ## 3b. Form Pages (Create / Edit)
450
173
 
451
- All forms must be full pages with their own URL route. Do not use modals, dialogs, drawers, or popups for create/edit forms.
174
+ All forms MUST be full pages with their own URL route. Do NOT use modals, dialogs, drawers, or popups.
452
175
 
453
176
  ### Route Convention
454
177
 
455
- Route paths must use **kebab-case** matching the navigation seed data (which uses `ToKebabCase()`).
456
- - Single word: `employees` (no change needed)
457
- - Multi-word: `human-resources`, `time-management` (kebab-case with hyphens)
458
- - Incorrect: `humanresources`, `timemanagement` (concatenated words without hyphens)
459
-
460
- | Action | Route pattern | Page component | File location |
461
- |--------|--------------|----------------|---------------|
462
- | Create | `/{module}/create` | `EntityCreatePage` | `src/pages/{App}/{Module}/EntityCreatePage.tsx` |
463
- | Edit | `/{module}/:id/edit` | `EntityEditPage` | `src/pages/{App}/{Module}/EntityEditPage.tsx` |
464
-
465
- ### Create Page Template
466
-
467
- ```tsx
468
- import { useState } from 'react';
469
- import { useTranslation } from 'react-i18next';
470
- import { useNavigate } from 'react-router-dom';
471
- import { ArrowLeft } from 'lucide-react';
472
- // For FK Guid fields: import { EntityLookup } from '@/components/ui/EntityLookup';
473
-
474
- export function EntityCreatePage() {
475
- const { t } = useTranslation(['{module}']);
476
- const navigate = useNavigate();
477
- const [submitting, setSubmitting] = useState(false);
478
- const [error, setError] = useState<string | null>(null);
479
- const [formData, setFormData] = useState<CreateEntityDto>({
480
- name: '',
481
- // departmentId: '', ← FK Guid field (use EntityLookup below)
482
- });
483
-
484
- const handleSubmit = async (e: React.FormEvent) => {
485
- e.preventDefault();
486
- try {
487
- setSubmitting(true);
488
- setError(null);
489
- await entityApi.create(formData);
490
- navigate(-1); // Back to list
491
- } catch (err: any) {
492
- setError(err.message || t('{module}:errors.createFailed', 'Creation failed'));
493
- } finally {
494
- setSubmitting(false);
495
- }
496
- };
497
-
498
- return (
499
- <div className="space-y-6">
500
- {/* Back button */}
501
- <button
502
- onClick={() => navigate(-1)}
503
- className="flex items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
504
- >
505
- <ArrowLeft className="w-4 h-4" />
506
- {t('common:actions.back', 'Back')}
507
- </button>
508
-
509
- {/* Page title */}
510
- <h1 className="text-2xl font-bold text-[var(--text-primary)]">
511
- {t('{module}:actions.create', 'Create {Entity}')}
512
- </h1>
513
-
514
- {/* Error state */}
515
- {error && (
516
- <div className="p-4 bg-[var(--error-bg)] border border-[var(--error-border)] rounded-[var(--radius-card)]">
517
- <span className="text-[var(--error-text)]">{error}</span>
518
- </div>
519
- )}
520
-
521
- {/* Form page — not modal */}
522
- <form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
523
- {/* Text field */}
524
- <div>
525
- <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
526
- {t('{module}:form.name', 'Name')}
527
- </label>
528
- <input
529
- type="text"
530
- value={formData.name}
531
- onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
532
- className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--bg-card)] text-[var(--text-primary)] rounded-[var(--radius-input)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-500)]"
533
- required
534
- />
535
- </div>
536
-
537
- {/* FK Guid field — use EntityLookup, not <select> or <input> */}
538
- {/* <div>
539
- <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
540
- {t('{module}:form.department', 'Department')}
541
- </label>
542
- <EntityLookup
543
- apiEndpoint="/api/{app}/{module}/departments"
544
- value={formData.departmentId}
545
- onChange={(id) => setFormData(prev => ({ ...prev, departmentId: id }))}
546
- mapOption={(dept) => ({ label: dept.name, value: dept.id })}
547
- placeholder={t('{module}:form.selectDepartment', 'Select a department...')}
548
- />
549
- </div> */}
550
-
551
- {/* Actions */}
552
- <div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
553
- <button
554
- type="button"
555
- onClick={() => navigate(-1)}
556
- className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]"
557
- >
558
- {t('common:actions.cancel', 'Cancel')}
559
- </button>
560
- <button
561
- type="submit"
562
- disabled={submitting}
563
- className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)] hover:bg-[var(--color-accent-600)] disabled:opacity-50"
564
- >
565
- {submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
566
- </button>
567
- </div>
568
- </form>
569
- </div>
570
- );
571
- }
572
- ```
178
+ Route paths MUST use **kebab-case** matching navigation seed data (which uses `ToKebabCase()`).
573
179
 
574
- ### Edit Page Template
180
+ | Action | Route | Page component |
181
+ |--------|-------|---------------|
182
+ | Create | `/{module}/create` | `EntityCreatePage` |
183
+ | Edit | `/{module}/:id/edit` | `EntityEditPage` |
575
184
 
576
- ```tsx
577
- import { useState, useEffect, useCallback } from 'react';
578
- import { useTranslation } from 'react-i18next';
579
- import { useNavigate, useParams } from 'react-router-dom';
580
- import { Loader2, ArrowLeft } from 'lucide-react';
581
- // For FK Guid fields: import { EntityLookup } from '@/components/ui/EntityLookup';
582
-
583
- export function EntityEditPage() {
584
- const { entityId } = useParams<{ entityId: string }>();
585
- const { t } = useTranslation(['{module}']);
586
- const navigate = useNavigate();
587
- const [formData, setFormData] = useState<UpdateEntityDto | null>(null);
588
- const [loading, setLoading] = useState(true);
589
- const [submitting, setSubmitting] = useState(false);
590
- const [error, setError] = useState<string | null>(null);
591
-
592
- const loadEntity = useCallback(async () => {
593
- try {
594
- setLoading(true);
595
- const result = await entityApi.getById(entityId!);
596
- setFormData(result);
597
- } catch {
598
- navigate(-1);
599
- } finally {
600
- setLoading(false);
601
- }
602
- }, [entityId, navigate]);
603
-
604
- useEffect(() => { loadEntity(); }, [loadEntity]);
605
-
606
- if (loading || !formData) {
607
- return (
608
- <div className="flex items-center justify-center min-h-[400px]">
609
- <Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
610
- </div>
611
- );
612
- }
185
+ > **v3.7+ (DynamicRouter):** Routes are resolved automatically from navigation seed data.
186
+ > `ComponentKey` in seed data matches `PageRegistry.register('key', ...)` entries.
187
+ > No manual route registration needed.
613
188
 
614
- const handleSubmit = async (e: React.FormEvent) => {
615
- e.preventDefault();
616
- try {
617
- setSubmitting(true);
618
- setError(null);
619
- await entityApi.update(entityId!, formData);
620
- navigate(-1); // Back to detail or list
621
- } catch (err: any) {
622
- setError(err.message || t('{module}:errors.updateFailed', 'Update failed'));
623
- } finally {
624
- setSubmitting(false);
625
- }
626
- };
627
-
628
- return (
629
- <div className="space-y-6">
630
- {/* Back button */}
631
- <button
632
- onClick={() => navigate(-1)}
633
- className="flex items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
634
- >
635
- <ArrowLeft className="w-4 h-4" />
636
- {t('common:actions.back', 'Back')}
637
- </button>
638
-
639
- {/* Page title */}
640
- <h1 className="text-2xl font-bold text-[var(--text-primary)]">
641
- {t('{module}:actions.edit', 'Edit {Entity}')}
642
- </h1>
643
-
644
- {/* Error state */}
645
- {error && (
646
- <div className="p-4 bg-[var(--error-bg)] border border-[var(--error-border)] rounded-[var(--radius-card)]">
647
- <span className="text-[var(--error-text)]">{error}</span>
648
- </div>
649
- )}
650
-
651
- {/* Form page — not modal */}
652
- <form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
653
- {/* Text field (pre-filled) */}
654
- <div>
655
- <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
656
- {t('{module}:form.name', 'Name')}
657
- </label>
658
- <input
659
- type="text"
660
- value={formData.name}
661
- onChange={(e) => setFormData(prev => prev ? { ...prev, name: e.target.value } : prev)}
662
- className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--bg-card)] text-[var(--text-primary)] rounded-[var(--radius-input)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-500)]"
663
- required
664
- />
665
- </div>
666
-
667
- {/* FK Guid field — use EntityLookup, not <select> or <input> */}
668
- {/* <div>
669
- <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
670
- {t('{module}:form.department', 'Department')}
671
- </label>
672
- <EntityLookup
673
- apiEndpoint="/api/{app}/{module}/departments"
674
- value={formData.departmentId}
675
- onChange={(id) => setFormData(prev => prev ? { ...prev, departmentId: id } : prev)}
676
- mapOption={(dept) => ({ label: dept.name, value: dept.id })}
677
- placeholder={t('{module}:form.selectDepartment', 'Select a department...')}
678
- />
679
- </div> */}
680
-
681
- {/* Actions */}
682
- <div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
683
- <button
684
- type="button"
685
- onClick={() => navigate(-1)}
686
- className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]"
687
- >
688
- {t('common:actions.cancel', 'Cancel')}
689
- </button>
690
- <button
691
- type="submit"
692
- disabled={submitting}
693
- className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)] hover:bg-[var(--color-accent-600)] disabled:opacity-50"
694
- >
695
- {submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
696
- </button>
697
- </div>
698
- </form>
699
- </div>
700
- );
701
- }
702
- ```
189
+ ### Route Registration
703
190
 
704
- ### Lazy Loading for Form Pages
191
+ > **Note (v3.7+):** With DynamicRouter + PageRegistry, route registration is automatic.
192
+ > Run `scaffold_routes outputFormat="componentRegistry"` to generate `componentRegistry.generated.ts`,
193
+ > then ensure `main.tsx` imports it. The patterns below apply only to legacy projects.
705
194
 
706
195
  ```tsx
707
- // In route files form pages are also lazy-loaded
708
- // Path: @/pages/{App}/{Module}/{Section}/{Page}
709
- const EntityCreatePage = lazy(() =>
710
- import('@/pages/HumanResources/EmployeeManagement/Employees/EntityCreatePage')
711
- .then(m => ({ default: m.EntityCreatePage }))
712
- );
713
- const EntityEditPage = lazy(() =>
714
- import('@/pages/HumanResources/EmployeeManagement/Employees/EntityEditPage')
715
- .then(m => ({ default: m.EntityEditPage }))
716
- );
717
-
718
- // Route registration — Pattern B (JSX nested children):
719
- {
720
- path: 'employee-management/employees',
721
- children: [
722
- { index: true, element: <Suspense fallback={<PageLoader />}><EmployeesPage /></Suspense> },
723
- { path: 'create', element: <Suspense fallback={<PageLoader />}><EntityCreatePage /></Suspense> },
724
- { path: ':id', element: <Suspense fallback={<PageLoader />}><EntityDetailPage /></Suspense> },
725
- { path: ':id/edit', element: <Suspense fallback={<PageLoader />}><EntityEditPage /></Suspense> },
726
- ]
727
- }
728
-
729
- // Route registration — Pattern A (applicationRoutes flat paths):
730
- // CRITICAL: paths are RELATIVE to /{app}/ and MUST include the module segment
196
+ // Pattern B (JSX nested children):
197
+ { path: '{module-kebab}', children: [
198
+ { index: true, element: <Suspense fallback={<PageLoader />}><ListPage /></Suspense> },
199
+ { path: 'create', element: <Suspense fallback={<PageLoader />}><CreatePage /></Suspense> },
200
+ { path: ':id', element: <Suspense fallback={<PageLoader />}><DetailPage /></Suspense> },
201
+ { path: ':id/edit', element: <Suspense fallback={<PageLoader />}><EditPage /></Suspense> },
202
+ ]}
203
+
204
+ // Pattern A (applicationRoutes MUST include module segment in path):
731
205
  const applicationRoutes: ApplicationRouteExtensions = {
732
206
  'human-resources': [
733
- { path: 'employee-management/employees', element: <EmployeesPage /> }, // includes module
734
- { path: 'employee-management/employees/create', element: <CreateEmployeePage /> }, //
735
- { path: 'employee-management/employees/:id', element: <EmployeeDetailPage /> }, //
736
- { path: 'employee-management/employees/:id/edit', element: <EditEmployeePage /> }, //
737
- // WRONG: { path: 'employees', ... } — missing module segment → 404 on nav click
207
+ { path: 'employee-management/employees', element: <ListPage /> }, // includes module
208
+ { path: 'employee-management/employees/create', element: <CreatePage /> }, // includes module
209
+ { path: 'employee-management/employees/:id', element: <DetailPage /> }, // includes module
210
+ { path: 'employee-management/employees/:id/edit', element: <EditPage /> }, // includes module
211
+ // WRONG: { path: 'employees', ... } — missing module segment → 404
738
212
  ],
739
213
  };
214
+ ```
740
215
 
741
- // Section-level routes children of the module route (when module has sections)
742
- //
743
- // > **IMPORTANT:** The `list` and `detail` sections do NOT generate additional route entries.
744
- // > They are already covered by the module's `index: true` (list) and `path: ':id'` (detail) routes above.
745
- // > Only sections like `dashboard`, `approve`, `import`, etc. generate the section-kebab child routes below.
746
- // Note: Do not use `path: 'list'` or `path: 'detail'` — these create unreachable duplicate routes.
747
- //
748
- {
749
- path: '{module-kebab}',
750
- children: [
751
- { index: true, element: <Suspense fallback={<PageLoader />}><{Module}Page /></Suspense> },
752
- { path: 'create', element: <Suspense fallback={<PageLoader />}><Create{Module}Page /></Suspense> },
753
- { path: ':id', element: <Suspense fallback={<PageLoader />}><{Module}DetailPage /></Suspense> },
754
- { path: ':id/edit', element: <Suspense fallback={<PageLoader />}><Edit{Module}Page /></Suspense> },
755
- // Section routes as children of module:
756
- // IMPORTANT: "list" and "detail" are NOT separate path segments.
757
- // - "list" section = already handled by the module's index route above (index: true)
758
- // - "detail" section = already handled by the module's :id route above (path: ':id')
759
- // - Only OTHER sections (dashboard, approve, import, etc.) add path segments:
760
- { path: '{section-kebab}', element: <Suspense fallback={<PageLoader />}><{Section}Page /></Suspense> },
761
- { path: '{section-kebab}/create', element: <Suspense fallback={<PageLoader />}><Create{Section}Page /></Suspense> },
762
- { path: '{section-kebab}/:id', element: <Suspense fallback={<PageLoader />}><{Section}DetailPage /></Suspense> },
763
- { path: '{section-kebab}/:id/edit', element: <Suspense fallback={<PageLoader />}><Edit{Section}Page /></Suspense> },
764
- ]
765
- }
216
+ **Section-level routes:** `list` and `detail` do NOT add path segments (handled by `index: true` and `:id`). Only other sections (dashboard, approve, import) add `{section-kebab}` child routes.
766
217
 
767
- // PermissionGuard for section-level routes
218
+ **PermissionGuard for sections:**
219
+ ```tsx
768
220
  element: (
769
221
  <Suspense fallback={<PageLoader />}>
770
222
  <PermissionGuard permissions={ROUTES['app.module.section'].permissions}>
@@ -774,30 +226,13 @@ element: (
774
226
  )
775
227
  ```
776
228
 
777
- ### Rules
778
-
779
- - Do not use `<Modal>`, `<Dialog>`, `<Drawer>`, or `<Popup>` for create/edit forms
780
- - Do not use `useState(isOpen)` to toggle form visibility — forms are pages, not overlays
781
- - Create a dedicated `EntityCreatePage.tsx` and `EntityEditPage.tsx` page component
782
- - Register create/edit routes alongside list/detail routes
783
- - Use `navigate('create')` or `navigate(\`${id}/edit\`)` from list/detail pages
784
- - Include a back button that uses `navigate(-1)` to return to previous page
785
-
786
- **Incorrect patterns:**
787
- ```tsx
788
- // WRONG: modal for create form
789
- const [showCreateModal, setShowCreateModal] = useState(false);
790
- <Modal open={showCreateModal}><CreateForm /></Modal>
791
-
792
- // WRONG: dialog for edit form
793
- <Dialog open={editDialogOpen}><EditForm entity={selected} /></Dialog>
794
-
795
- // WRONG: drawer for form
796
- <Drawer open={isDrawerOpen}><form>...</form></Drawer>
797
-
798
- // WRONG: inline form toggle
799
- {isEditing ? <EditForm /> : <DetailView />}
800
- ```
229
+ **Rules:**
230
+ - Do NOT use `<Modal>`, `<Dialog>`, `<Drawer>` for create/edit forms
231
+ - Do NOT use `useState(isOpen)` to toggle form visibility forms are pages
232
+ - Navigate: `navigate('create')` or `navigate(\`${id}/edit\`)`
233
+ - Back button: `navigate(-1)`
234
+ - Create dedicated `EntityCreatePage.tsx` and `EntityEditPage.tsx` page components
235
+ - Form submitting state: `setSubmitting(true)` API call → `navigate(-1)` on success
801
236
 
802
237
  ---
803
238
 
@@ -805,10 +240,8 @@ const [showCreateModal, setShowCreateModal] = useState(false);
805
240
 
806
241
  Do not use hardcoded Tailwind colors. Always use CSS variables for theme support.
807
242
 
808
- ### Variable Reference
809
-
810
- | Usage | CSS Variable | Example |
811
- |-------|-------------|---------|
243
+ | Usage | CSS Variable | Tailwind usage |
244
+ |-------|-------------|---------------|
812
245
  | Background | `var(--bg-primary)` | `bg-[var(--bg-primary)]` |
813
246
  | Card background | `var(--bg-card)` | `bg-[var(--bg-card)]` |
814
247
  | Text primary | `var(--text-primary)` | `text-[var(--text-primary)]` |
@@ -816,9 +249,13 @@ Do not use hardcoded Tailwind colors. Always use CSS variables for theme support
816
249
  | Borders | `var(--border-color)` | `border-[var(--border-color)]` |
817
250
  | Accent | `var(--color-accent-500)` | `text-[var(--color-accent-500)]` |
818
251
  | Card radius | `var(--radius-card)` | `style={{ borderRadius: 'var(--radius-card)' }}` |
252
+ | Input radius | `var(--radius-input)` | `rounded-[var(--radius-input)]` |
253
+ | Button radius | `var(--radius-button)` | `rounded-[var(--radius-button)]` |
254
+ | Hover background | `var(--bg-hover)` | `hover:bg-[var(--bg-hover)]` |
255
+ | Success states | `var(--success-bg)`, `var(--success-text)` | Status badges |
256
+ | Error states | `var(--error-bg)`, `var(--error-text)`, `var(--error-border)` | Error displays |
819
257
 
820
- ### Card Pattern
821
-
258
+ **Card pattern:**
822
259
  ```tsx
823
260
  <div
824
261
  className="bg-[var(--bg-card)] border border-[var(--border-color)] p-6"
@@ -829,64 +266,50 @@ Do not use hardcoded Tailwind colors. Always use CSS variables for theme support
829
266
  </div>
830
267
  ```
831
268
 
832
- **Incorrect:**
833
- ```tsx
834
- // Do not use hardcoded Tailwind colors
835
- className="bg-white border-gray-200 text-gray-900"
836
-
837
- // Do not use hardcoded hex/rgb
838
- style={{ backgroundColor: '#ffffff', color: '#1a1a1a' }}
839
- ```
269
+ Do NOT use: `bg-white`, `border-gray-200`, `text-gray-900`, hardcoded hex/rgb.
840
270
 
841
271
  ---
842
272
 
843
273
  ## 5. Component Rules
844
274
 
845
- | Need | Component | Source | Notes |
846
- |------|-----------|--------|-------|
847
- | Data table | `DataTable` | `@/components/ui/DataTable` | Shared component (sorting, pagination, search) |
848
- | Entity cards | `EntityCard` | `@/components/ui/EntityCard` | Shared component (avatar, badges, actions) |
849
- | FK field lookup | `EntityLookup` | Generate in `@/components/ui/EntityLookup` | See section 6 for full pattern |
850
- | KPI statistics | `StatCard` | Generate locally per dashboard | See dashboard-chart.md pattern |
851
- | Chart wrapper | `ChartCard` | Generate locally per dashboard | See dashboard-chart.md pattern |
852
- | Loading spinner | `Loader2` | `lucide-react` | Shared |
853
- | Page loader | `PageLoader` | `@/components/ui/PageLoader` | Shared (Suspense fallback) |
854
- | Docs toggle | `DocToggleButton` | `@/components/docs/DocToggleButton` | Shared |
855
-
856
- ### Rules
857
-
858
- - Do not use raw `<table>` — use `DataTable` from `@/components/ui/DataTable`
275
+ | Need | Component | Source |
276
+ |------|-----------|--------|
277
+ | Data table | `DataTable` | `@/components/ui/DataTable` |
278
+ | Entity cards | `EntityCard` | `@/components/ui/EntityCard` |
279
+ | FK field lookup | `EntityLookup` | Generate via `/ui-components` (see §6) |
280
+ | KPI statistics | `StatCard` | Generate locally per dashboard |
281
+ | Loading spinner | `Loader2` | `lucide-react` |
282
+ | Page loader | `PageLoader` | `@/components/ui/PageLoader` |
283
+ | Docs toggle | `DocToggleButton` | `@/components/docs/DocToggleButton` |
284
+
285
+ - Do not use raw `<table>` — use `DataTable`
859
286
  - Do not create custom spinners — use `Loader2` from lucide-react
860
287
  - Do not import axios directly — use `@/services/api/apiClient`
861
288
  - Use `PageLoader` as Suspense fallback
862
- - Use existing shared components before creating new ones
863
289
  - Use `EntityLookup` for FK Guid fields (not `<select>` or `<input>` for GUIDs)
864
290
 
865
291
  ---
866
292
 
867
293
  ## 6. Foreign Key Fields & Entity Lookup
868
294
 
869
- Do not render a foreign key (Guid) as a plain text input. FK fields must use a searchable lookup component.
870
- A form asking the user to type a GUID manually is a UX failure. All FK fields must provide entity search and selection.
295
+ Do not render FK (Guid) fields as plain text input or `<select>`. FK fields MUST use `EntityLookup`.
871
296
 
872
297
  ### Field Type Classification
873
298
 
874
- When generating form fields, determine the field type from the entity property:
875
-
876
- | Property type | Form field type | Component |
877
- |---------------|----------------|-----------|
878
- | `string` | Text input | `<input type="text" />` |
879
- | `string?` | Text input (optional) | `<input type="text" />` |
299
+ | Property type | Form field | Component |
300
+ |---------------|-----------|-----------|
301
+ | `string` / `string?` | Text input | `<input type="text" />` |
880
302
  | `Guid` (FK — e.g., `EmployeeId`, `DepartmentId`) | **Entity Lookup** | `<EntityLookup />` |
881
303
  | `bool` | Toggle/Checkbox | `<input type="checkbox" />` |
882
304
  | `int` / `decimal` | Number input | `<input type="number" />` |
883
- | `DateTime` / `DateOnly` | Date input | `<input type="date" className="..." />` — wrap with label + error state using CSS variables |
305
+ | `DateTime` / `DateOnly` | Date input | `<input type="date" />` with CSS variables |
884
306
  | `enum` | Select dropdown | `<select>` |
885
307
 
886
- #### Date Field Pattern (styled with CSS variables)
308
+ **How to detect FK fields:** Any property named `{Entity}Id` of type `Guid` with a corresponding navigation property.
309
+
310
+ #### Date Field Pattern
887
311
 
888
312
  ```tsx
889
- {/* Date field — styled with CSS variables */}
890
313
  <div>
891
314
  <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
892
315
  {t('{module}:form.startDate', 'Start Date')}
@@ -901,362 +324,33 @@ When generating form fields, determine the field type from the entity property:
901
324
  </div>
902
325
  ```
903
326
 
904
- **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`.
905
-
906
- ### EntityLookup Component Pattern
907
-
908
- ```tsx
909
- import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
910
- import { useTranslation } from 'react-i18next';
911
- import { Search, X, ChevronDown } from 'lucide-react';
912
- import { apiClient } from '@/services/api/apiClient';
913
-
914
- interface EntityLookupOption {
915
- id: string;
916
- label: string; // Display name (e.g., employee full name)
917
- sublabel?: string; // Secondary info (e.g., department, code)
918
- }
327
+ ### EntityLookup Props
919
328
 
329
+ ```typescript
920
330
  interface EntityLookupProps {
921
- /** API endpoint to search entities (e.g., '/api/human-resources/employees') */
922
- apiEndpoint: string;
923
- /** Currently selected entity ID */
924
- value: string | null;
925
- /** Callback when entity is selected */
331
+ apiEndpoint: string; // e.g., '/api/human-resources/employees'
332
+ value: string | null; // Currently selected entity ID
926
333
  onChange: (id: string | null) => void;
927
- /** Field label */
928
- label: string;
929
- /** Placeholder text */
334
+ label: string; // Field label
930
335
  placeholder?: string;
931
- /** Map API response item to display option */
932
- mapOption: (item: any) => EntityLookupOption;
933
- /** Whether the field is required */
336
+ mapOption: (item: any) => { id: string; label: string; sublabel?: string };
934
337
  required?: boolean;
935
- /** Whether the field is disabled */
936
338
  disabled?: boolean;
937
- /** Error message to display */
938
339
  error?: string;
939
340
  }
940
-
941
- export function EntityLookup({
942
- apiEndpoint,
943
- value,
944
- onChange,
945
- label,
946
- placeholder,
947
- mapOption,
948
- required = false,
949
- disabled = false,
950
- error,
951
- }: EntityLookupProps) {
952
- const { t } = useTranslation(['common']);
953
- const [search, setSearch] = useState('');
954
- const [options, setOptions] = useState<EntityLookupOption[]>([]);
955
- const [selectedOption, setSelectedOption] = useState<EntityLookupOption | null>(null);
956
- const [isOpen, setIsOpen] = useState(false);
957
- const [loading, setLoading] = useState(false);
958
- const containerRef = useRef<HTMLDivElement>(null);
959
- const debounceRef = useRef<ReturnType<typeof setTimeout>>();
960
-
961
- // Load selected entity display on mount (when value is set but no label)
962
- useEffect(() => {
963
- if (value && !selectedOption) {
964
- apiClient.get(`${apiEndpoint}/${value}`)
965
- .then(res => setSelectedOption(mapOption(res.data)))
966
- .catch(() => { /* Entity not found — clear */ });
967
- }
968
- }, [value, apiEndpoint, mapOption, selectedOption]);
969
-
970
- // Debounced search — 300ms delay, minimum 2 characters
971
- const handleSearch = useCallback((term: string) => {
972
- setSearch(term);
973
- if (debounceRef.current) clearTimeout(debounceRef.current);
974
-
975
- if (term.length < 2) {
976
- setOptions([]);
977
- return;
978
- }
979
-
980
- debounceRef.current = setTimeout(async () => {
981
- setLoading(true);
982
- try {
983
- const res = await apiClient.get(apiEndpoint, {
984
- params: { search: term, pageSize: 20 },
985
- });
986
- setOptions((res.data.items || res.data).map(mapOption));
987
- } catch {
988
- setOptions([]);
989
- } finally {
990
- setLoading(false);
991
- }
992
- }, 300);
993
- }, [apiEndpoint, mapOption]);
994
-
995
- // Load initial options when dropdown opens (show first 20)
996
- const handleOpen = useCallback(async () => {
997
- if (disabled) return;
998
- setIsOpen(true);
999
- if (options.length === 0 && search.length < 2) {
1000
- setLoading(true);
1001
- try {
1002
- const res = await apiClient.get(apiEndpoint, {
1003
- params: { pageSize: 20 },
1004
- });
1005
- setOptions((res.data.items || res.data).map(mapOption));
1006
- } catch {
1007
- setOptions([]);
1008
- } finally {
1009
- setLoading(false);
1010
- }
1011
- }
1012
- }, [disabled, apiEndpoint, mapOption, options.length, search.length]);
1013
-
1014
- // Select entity
1015
- const handleSelect = useCallback((option: EntityLookupOption) => {
1016
- setSelectedOption(option);
1017
- onChange(option.id);
1018
- setIsOpen(false);
1019
- setSearch('');
1020
- }, [onChange]);
1021
-
1022
- // Clear selection
1023
- const handleClear = useCallback(() => {
1024
- setSelectedOption(null);
1025
- onChange(null);
1026
- setSearch('');
1027
- }, [onChange]);
1028
-
1029
- // Close on outside click
1030
- useEffect(() => {
1031
- const handleClickOutside = (e: MouseEvent) => {
1032
- if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
1033
- setIsOpen(false);
1034
- }
1035
- };
1036
- document.addEventListener('mousedown', handleClickOutside);
1037
- return () => document.removeEventListener('mousedown', handleClickOutside);
1038
- }, []);
1039
-
1040
- return (
1041
- <div ref={containerRef} className="relative">
1042
- <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
1043
- {label} {required && <span className="text-[var(--error-text)]">*</span>}
1044
- </label>
1045
-
1046
- {/* Selected value display OR search input */}
1047
- {selectedOption && !isOpen ? (
1048
- <div className="flex items-center gap-2 px-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)]">
1049
- <div className="flex-1">
1050
- <span className="text-[var(--text-primary)]">{selectedOption.label}</span>
1051
- {selectedOption.sublabel && (
1052
- <span className="ml-2 text-sm text-[var(--text-secondary)]">{selectedOption.sublabel}</span>
1053
- )}
1054
- </div>
1055
- {!disabled && (
1056
- <button type="button" onClick={handleClear} className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
1057
- <X className="w-4 h-4" />
1058
- </button>
1059
- )}
1060
- <button type="button" onClick={handleOpen} className="text-[var(--text-secondary)]">
1061
- <ChevronDown className="w-4 h-4" />
1062
- </button>
1063
- </div>
1064
- ) : (
1065
- <div className="relative">
1066
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" />
1067
- <input
1068
- type="text"
1069
- value={search}
1070
- onChange={(e) => handleSearch(e.target.value)}
1071
- onFocus={handleOpen}
1072
- placeholder={placeholder || t('common:actions.search', 'Search...')}
1073
- disabled={disabled}
1074
- 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"
1075
- />
1076
- </div>
1077
- )}
1078
-
1079
- {/* Dropdown */}
1080
- {isOpen && (
1081
- <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">
1082
- {loading ? (
1083
- <div className="p-3 text-center text-[var(--text-secondary)]">
1084
- {t('common:actions.loading', 'Loading...')}
1085
- </div>
1086
- ) : options.length === 0 ? (
1087
- <div className="p-3 text-center text-[var(--text-secondary)]">
1088
- {search.length < 2
1089
- ? t('common:actions.typeToSearch', 'Type at least 2 characters to search...')
1090
- : t('common:empty.noResults', 'No results found')}
1091
- </div>
1092
- ) : (
1093
- options.map((option) => (
1094
- <button
1095
- key={option.id}
1096
- type="button"
1097
- onClick={() => handleSelect(option)}
1098
- className="w-full px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
1099
- >
1100
- <div className="text-[var(--text-primary)]">{option.label}</div>
1101
- {option.sublabel && (
1102
- <div className="text-sm text-[var(--text-secondary)]">{option.sublabel}</div>
1103
- )}
1104
- </button>
1105
- ))
1106
- )}
1107
- </div>
1108
- )}
1109
-
1110
- {/* Error message */}
1111
- {error && (
1112
- <p className="mt-1 text-sm text-[var(--error-text)]">{error}</p>
1113
- )}
1114
- </div>
1115
- );
1116
- }
1117
341
  ```
1118
342
 
1119
- ### Usage in Form Pages
343
+ ### Usage in Forms
1120
344
 
1121
345
  ```tsx
1122
- // In EntityCreatePage.tsx or EntityEditPage.tsx
1123
346
  import { EntityLookup } from '@/components/ui/EntityLookup';
1124
347
 
1125
- // Inside the form:
1126
- <EntityLookup
1127
- apiEndpoint="/api/human-resources/employees"
1128
- value={formData.employeeId}
1129
- onChange={(id) => handleChange('employeeId', id)}
1130
- label={t('module:form.employee', 'Employee')}
1131
- placeholder={t('module:form.employeePlaceholder', 'Search for an employee...')}
1132
- mapOption={(emp) => ({
1133
- id: emp.id,
1134
- label: `${emp.firstName} ${emp.lastName}`,
1135
- sublabel: emp.department || emp.code,
1136
- })}
1137
- required
1138
- error={errors.employeeId}
1139
- />
1140
-
1141
- // For DepartmentId FK:
1142
348
  <EntityLookup
1143
349
  apiEndpoint="/api/human-resources/departments"
1144
350
  value={formData.departmentId}
1145
351
  onChange={(id) => handleChange('departmentId', id)}
1146
352
  label={t('module:form.department', 'Department')}
1147
353
  placeholder={t('module:form.departmentPlaceholder', 'Search for a department...')}
1148
- mapOption={(dept) => ({
1149
- id: dept.id,
1150
- label: dept.name,
1151
- sublabel: dept.code,
1152
- })}
1153
- required
1154
- />
1155
- ```
1156
-
1157
- ### API Search Endpoint Convention (Backend)
1158
-
1159
- For EntityLookup to work, each entity's API MUST support search via query parameter:
1160
-
1161
- ```
1162
- GET /api/{resource}?search={term}&pageSize=20
1163
- ```
1164
-
1165
- Response format:
1166
- ```json
1167
- {
1168
- "items": [
1169
- { "id": "guid", "code": "EMP001", "name": "John Doe", ... }
1170
- ],
1171
- "totalCount": 42
1172
- }
1173
- ```
1174
-
1175
- The backend service's `GetAllAsync` method should accept search parameters:
1176
-
1177
- ```csharp
1178
- public async Task<PaginatedResult<EntityResponseDto>> GetAllAsync(
1179
- string? search = null,
1180
- int page = 1,
1181
- int pageSize = 20,
1182
- CancellationToken ct = default)
1183
- {
1184
- var query = _db.Entities
1185
- .Where(x => x.TenantId == _currentUser.TenantId);
1186
-
1187
- if (!string.IsNullOrWhiteSpace(search))
1188
- {
1189
- query = query.Where(x =>
1190
- x.Name.Contains(search) ||
1191
- x.Code.Contains(search));
1192
- }
1193
-
1194
- var totalCount = await query.CountAsync(ct);
1195
- var items = await query
1196
- .OrderBy(x => x.Name)
1197
- .Skip((page - 1) * pageSize)
1198
- .Take(pageSize)
1199
- .Select(x => new EntityResponseDto { ... })
1200
- .ToListAsync(ct);
1201
-
1202
- return new PaginatedResult<EntityResponseDto>(items, totalCount, page, pageSize);
1203
- }
1204
- ```
1205
-
1206
- ### Rules
1207
-
1208
- - Do not render a `Guid` FK field as `<input type="text">` — use `EntityLookup`
1209
- - Do not render a `Guid` FK field as `<select>` — even with API-loaded `<option>` elements, `<select>` is not acceptable
1210
- - Do not ask the user to manually type or paste a GUID/ID
1211
- - Provide a search-based selection via `<EntityLookup />` for FK fields
1212
- - Show the entity's display name (Name, FullName, Code+Name) not the GUID
1213
- - Include `mapOption` to define how the related entity is displayed
1214
- - Load the selected entity's display name on mount (for edit forms)
1215
- - Support clearing the selection (unless required + already set)
1216
-
1217
- **Why `<select>` is NOT acceptable for FK fields:**
1218
- - `<select>` loads ALL options at once — fails with 100+ entities (performance + UX)
1219
- - `<select>` has no search/filter — user must scroll through all options
1220
- - `<select>` cannot show sublabels (code, department, etc.)
1221
- - `EntityLookup` provides: debounced API search, paginated results, display name resolution, sublabels
1222
-
1223
- **Incorrect patterns:**
1224
- ```tsx
1225
- // Do not use plain text input for FK field
1226
- <input
1227
- type="text"
1228
- value={formData.employeeId}
1229
- onChange={(e) => handleChange('employeeId', e.target.value)}
1230
- placeholder="Enter Employee ID..."
1231
- />
1232
-
1233
- // Do not use <select> dropdown for FK field (even with API-loaded options)
1234
- <select
1235
- value={formData.departmentId}
1236
- onChange={(e) => setFormData({ ...formData, departmentId: e.target.value })}
1237
- >
1238
- <option value="">Select Department...</option>
1239
- {departments.map((dept) => (
1240
- <option key={dept.id} value={dept.id}>{dept.name}</option>
1241
- ))}
1242
- </select>
1243
-
1244
- // Do not display raw GUID to user
1245
- <span>{entity.departmentId}</span>
1246
-
1247
- // Do not use select with hardcoded options for FK
1248
- <select onChange={(e) => handleChange('departmentId', e.target.value)}>
1249
- <option value="guid-1">Department A</option>
1250
- </select>
1251
- ```
1252
-
1253
- **Correct pattern:**
1254
- ```tsx
1255
- <EntityLookup
1256
- apiEndpoint="/api/human-resources/departments"
1257
- value={formData.departmentId}
1258
- onChange={(id) => handleChange('departmentId', id)}
1259
- label={t('module:form.department', 'Department')}
1260
354
  mapOption={(dept) => ({ id: dept.id, label: dept.name, sublabel: dept.code })}
1261
355
  required
1262
356
  />
@@ -1264,7 +358,7 @@ public async Task<PaginatedResult<EntityResponseDto>> GetAllAsync(
1264
358
 
1265
359
  ### I18n Keys for EntityLookup
1266
360
 
1267
- Add these keys to the module's translation files:
361
+ Add to the module's translation files:
1268
362
 
1269
363
  ```json
1270
364
  {
@@ -1277,8 +371,26 @@ Add these keys to the module's translation files:
1277
371
  }
1278
372
  ```
1279
373
 
374
+ ### API Search Convention (Backend)
375
+
376
+ EntityLookup requires: `GET /api/{resource}?search={term}&pageSize=20`
377
+
378
+ Response format: `{ "items": [...], "totalCount": 42 }`
379
+
380
+ The backend service's `GetAllAsync` MUST accept `string? search` parameter and filter on display fields (Name, Code, etc.).
381
+
382
+ ### Rules
383
+
384
+ - Do NOT render Guid FK as `<input type="text">` — user should never type/paste a GUID
385
+ - Do NOT use `<select>` for FK fields — fails with 100+ entities (no search, loads all at once, no sublabels)
386
+ - Show entity display name (Name, FullName, Code+Name), not the GUID
387
+ - Include `mapOption` to define display mapping
388
+ - Support clearing selection (unless required + already set)
389
+ - Load selected entity display on mount (for edit forms with pre-existing value)
390
+ - Debounced search (300ms), minimum 2 characters
391
+ - Load initial options when dropdown opens (first 20)
392
+
1280
393
  ---
1281
394
 
1282
395
  > **Sections 7-9 (Documentation, Testing, Compliance Gates) have been moved to `references/smartstack-frontend-compliance.md` for context reduction.**
1283
396
  > Load that file for: DocToggleButton integration (§7), frontend checklist (§7b), cross-tenant UI patterns (§7c), form testing templates (§8), and 5 compliance gates (§9).
1284
-