@atlashub/smartstack-cli 4.32.0 → 4.34.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/index.html +2 -2
- package/.documentation/init.html +358 -174
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-entry.mjs +271 -44
- package/dist/mcp-entry.mjs.map +1 -1
- package/package.json +1 -1
- package/templates/mcp-scaffolding/controller.cs.hbs +54 -128
- package/templates/project/README.md +19 -0
- package/templates/project/claude-md/api.CLAUDE.md.template +315 -0
- package/templates/project/claude-md/application.CLAUDE.md.template +181 -0
- package/templates/project/claude-md/domain.CLAUDE.md.template +125 -0
- package/templates/project/claude-md/infrastructure.CLAUDE.md.template +168 -0
- package/templates/project/claude-md/root.CLAUDE.md.template +339 -0
- package/templates/project/claude-md/web.CLAUDE.md.template +339 -0
- package/templates/skills/apex/SKILL.md +16 -10
- package/templates/skills/apex/_shared.md +1 -1
- package/templates/skills/apex/references/checks/architecture-checks.sh +154 -0
- package/templates/skills/apex/references/checks/backend-checks.sh +194 -0
- package/templates/skills/apex/references/checks/frontend-checks.sh +448 -0
- package/templates/skills/apex/references/checks/infrastructure-checks.sh +255 -0
- package/templates/skills/apex/references/checks/security-checks.sh +153 -0
- package/templates/skills/apex/references/checks/seed-checks.sh +536 -0
- package/templates/skills/apex/references/frontend-route-wiring-app-tsx.md +49 -192
- package/templates/skills/apex/references/post-checks.md +124 -2156
- package/templates/skills/apex/references/smartstack-api.md +160 -957
- package/templates/skills/apex/references/smartstack-frontend.md +134 -1022
- package/templates/skills/apex/references/smartstack-layers.md +12 -6
- package/templates/skills/apex/steps/step-00-init.md +81 -238
- package/templates/skills/apex/steps/step-03-execute.md +25 -752
- package/templates/skills/apex/steps/step-03a-layer0-domain.md +118 -0
- package/templates/skills/apex/steps/step-03b-layer1-seed.md +91 -0
- package/templates/skills/apex/steps/step-03c-layer2-backend.md +240 -0
- package/templates/skills/apex/steps/step-03d-layer3-frontend.md +300 -0
- package/templates/skills/apex/steps/step-03e-layer4-devdata.md +44 -0
- package/templates/skills/apex/steps/step-04-examine.md +70 -150
- package/templates/skills/application/references/frontend-i18n-and-output.md +2 -2
- package/templates/skills/application/references/frontend-route-naming.md +5 -1
- package/templates/skills/application/references/frontend-route-wiring-app-tsx.md +49 -198
- package/templates/skills/application/references/frontend-verification.md +11 -11
- package/templates/skills/application/steps/step-05-frontend.md +26 -15
- package/templates/skills/application/templates-frontend.md +4 -0
- package/templates/skills/cli-app-sync/SKILL.md +2 -2
- package/templates/skills/cli-app-sync/references/comparison-map.md +1 -1
- package/templates/skills/controller/references/controller-code-templates.md +70 -67
- 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-
|
|
4
|
-
> **Purpose:**
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
48
|
-
-
|
|
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
|
-
|
|
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
|
|
115
|
+
### Usage
|
|
175
116
|
|
|
176
117
|
```tsx
|
|
177
|
-
// Hook — specify namespace(s)
|
|
178
118
|
const { t } = useTranslation(['employees']);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
t('
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
-
|
|
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
|
|
145
|
+
## 3. Page Structure
|
|
245
146
|
|
|
246
|
-
>
|
|
147
|
+
> `/ui-components` generates complete pages. These rules ensure compliance.
|
|
247
148
|
|
|
248
|
-
### Standard List Page
|
|
149
|
+
### Standard List Page Structure
|
|
249
150
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
**
|
|
414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
172
|
+
## 3b. Form Pages (Create / Edit)
|
|
450
173
|
|
|
451
|
-
All 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
|
|
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
|
-
|
|
180
|
+
| Action | Route | Page component |
|
|
181
|
+
|--------|-------|---------------|
|
|
182
|
+
| Create | `/{module}/create` | `EntityCreatePage` |
|
|
183
|
+
| Edit | `/{module}/:id/edit` | `EntityEditPage` |
|
|
575
184
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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: <
|
|
734
|
-
{ path: 'employee-management/employees/create', element: <
|
|
735
|
-
{ path: 'employee-management/employees/:id', element: <
|
|
736
|
-
{ path: 'employee-management/employees/:id/edit', element: <
|
|
737
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
- Do
|
|
780
|
-
-
|
|
781
|
-
-
|
|
782
|
-
-
|
|
783
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
846
|
-
|
|
847
|
-
| Data table | `DataTable` | `@/components/ui/DataTable` |
|
|
848
|
-
| Entity cards | `EntityCard` | `@/components/ui/EntityCard` |
|
|
849
|
-
| FK field lookup | `EntityLookup` | Generate
|
|
850
|
-
| KPI statistics | `StatCard` | Generate locally per dashboard |
|
|
851
|
-
|
|
|
852
|
-
|
|
|
853
|
-
|
|
|
854
|
-
|
|
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
|
|
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
|
-
|
|
875
|
-
|
|
876
|
-
|
|
|
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"
|
|
305
|
+
| `DateTime` / `DateOnly` | Date input | `<input type="date" />` with CSS variables |
|
|
884
306
|
| `enum` | Select dropdown | `<select>` |
|
|
885
307
|
|
|
886
|
-
|
|
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
|
-
|
|
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
|
-
|
|
922
|
-
|
|
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
|
-
|
|
928
|
-
label: string;
|
|
929
|
-
/** Placeholder text */
|
|
334
|
+
label: string; // Field label
|
|
930
335
|
placeholder?: string;
|
|
931
|
-
|
|
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
|
|
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
|
|
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
|
-
|