@atlashub/smartstack-cli 4.4.0 → 4.6.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/dist/index.js +26 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/project/DependencyInjection.Application.cs.template +2 -2
- package/templates/project/Program.cs.template +91 -37
- package/templates/project/appsettings.json.template +34 -7
- package/templates/skills/apex/references/person-extension-pattern.md +7 -5
- package/templates/skills/apex/references/smartstack-frontend.md +278 -103
- package/templates/skills/apex/references/smartstack-layers.md +2 -2
- package/templates/skills/apex/steps/step-03-execute.md +18 -1
- package/templates/skills/apex/steps/step-05-deep-review.md +1 -1
- package/templates/skills/ralph-loop/references/task-transform-legacy.md +2 -2
- package/templates/skills/ui-components/SKILL.md +310 -0
- package/templates/skills/ui-components/patterns/data-table.md +136 -0
|
@@ -251,6 +251,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
251
251
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
252
252
|
import { Loader2 } from 'lucide-react';
|
|
253
253
|
import { DocToggleButton } from '@/components/docs/DocToggleButton';
|
|
254
|
+
import { DataTable } from '@/components/ui/DataTable';
|
|
254
255
|
|
|
255
256
|
// API hook (generated by scaffold_api_client)
|
|
256
257
|
import { useEntityList } from '@/hooks/useEntity';
|
|
@@ -328,7 +329,34 @@ export function EntityListPage() {
|
|
|
328
329
|
</div>
|
|
329
330
|
</div>
|
|
330
331
|
|
|
331
|
-
{/* Content:
|
|
332
|
+
{/* Content: DataTable with row click → detail */}
|
|
333
|
+
{data.length === 0 ? (
|
|
334
|
+
<div className="text-center py-12 text-[var(--text-secondary)]">
|
|
335
|
+
{t('{module}:empty', 'No items found.')}
|
|
336
|
+
</div>
|
|
337
|
+
) : (
|
|
338
|
+
<DataTable
|
|
339
|
+
data={data}
|
|
340
|
+
columns={[
|
|
341
|
+
{ key: 'name', label: t('{module}:columns.name', 'Name'), sortable: true },
|
|
342
|
+
{ key: 'code', label: t('{module}:columns.code', 'Code'), sortable: true },
|
|
343
|
+
{ key: 'status', label: t('{module}:columns.status', 'Status'),
|
|
344
|
+
render: (item) => (
|
|
345
|
+
<span className={`px-2 py-0.5 rounded text-xs ${
|
|
346
|
+
item.isActive
|
|
347
|
+
? 'bg-[var(--success-bg)] text-[var(--success-text)]'
|
|
348
|
+
: 'bg-[var(--error-bg)] text-[var(--error-text)]'
|
|
349
|
+
}`}>
|
|
350
|
+
{item.isActive ? t('common:status.active', 'Active') : t('common:status.inactive', 'Inactive')}
|
|
351
|
+
</span>
|
|
352
|
+
)
|
|
353
|
+
},
|
|
354
|
+
]}
|
|
355
|
+
searchable
|
|
356
|
+
pagination={{ pageSize: 10 }}
|
|
357
|
+
onRowClick={(item) => navigate(`${item.id}`)}
|
|
358
|
+
/>
|
|
359
|
+
)}
|
|
332
360
|
</div>
|
|
333
361
|
);
|
|
334
362
|
}
|
|
@@ -434,19 +462,28 @@ const handleTabClick = (tab: TabKey) => {
|
|
|
434
462
|
import { useState } from 'react';
|
|
435
463
|
import { useTranslation } from 'react-i18next';
|
|
436
464
|
import { useNavigate } from 'react-router-dom';
|
|
465
|
+
import { ArrowLeft } from 'lucide-react';
|
|
466
|
+
// For FK Guid fields: import { EntityLookup } from '@/components/ui/EntityLookup';
|
|
437
467
|
|
|
438
468
|
export function EntityCreatePage() {
|
|
439
469
|
const { t } = useTranslation(['{module}']);
|
|
440
470
|
const navigate = useNavigate();
|
|
441
471
|
const [submitting, setSubmitting] = useState(false);
|
|
472
|
+
const [error, setError] = useState<string | null>(null);
|
|
473
|
+
const [formData, setFormData] = useState<CreateEntityDto>({
|
|
474
|
+
name: '',
|
|
475
|
+
// departmentId: '', ← FK Guid field (use EntityLookup below)
|
|
476
|
+
});
|
|
442
477
|
|
|
443
|
-
const handleSubmit = async (
|
|
478
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
479
|
+
e.preventDefault();
|
|
444
480
|
try {
|
|
445
481
|
setSubmitting(true);
|
|
446
|
-
|
|
482
|
+
setError(null);
|
|
483
|
+
await entityApi.create(formData);
|
|
447
484
|
navigate(-1); // Back to list
|
|
448
485
|
} catch (err: any) {
|
|
449
|
-
|
|
486
|
+
setError(err.message || t('{module}:errors.createFailed', 'Creation failed'));
|
|
450
487
|
} finally {
|
|
451
488
|
setSubmitting(false);
|
|
452
489
|
}
|
|
@@ -457,8 +494,9 @@ export function EntityCreatePage() {
|
|
|
457
494
|
{/* Back button */}
|
|
458
495
|
<button
|
|
459
496
|
onClick={() => navigate(-1)}
|
|
460
|
-
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
497
|
+
className="flex items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
461
498
|
>
|
|
499
|
+
<ArrowLeft className="w-4 h-4" />
|
|
462
500
|
{t('common:actions.back', 'Back')}
|
|
463
501
|
</button>
|
|
464
502
|
|
|
@@ -467,13 +505,61 @@ export function EntityCreatePage() {
|
|
|
467
505
|
{t('{module}:actions.create', 'Create {Entity}')}
|
|
468
506
|
</h1>
|
|
469
507
|
|
|
470
|
-
{/*
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
508
|
+
{/* Error state */}
|
|
509
|
+
{error && (
|
|
510
|
+
<div className="p-4 bg-[var(--error-bg)] border border-[var(--error-border)] rounded-[var(--radius-card)]">
|
|
511
|
+
<span className="text-[var(--error-text)]">{error}</span>
|
|
512
|
+
</div>
|
|
513
|
+
)}
|
|
514
|
+
|
|
515
|
+
{/* Form — NEVER in a modal */}
|
|
516
|
+
<form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
|
|
517
|
+
{/* Text field */}
|
|
518
|
+
<div>
|
|
519
|
+
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
520
|
+
{t('{module}:form.name', 'Name')}
|
|
521
|
+
</label>
|
|
522
|
+
<input
|
|
523
|
+
type="text"
|
|
524
|
+
value={formData.name}
|
|
525
|
+
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
|
526
|
+
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)]"
|
|
527
|
+
required
|
|
528
|
+
/>
|
|
529
|
+
</div>
|
|
530
|
+
|
|
531
|
+
{/* FK Guid field — ALWAYS use EntityLookup, NEVER <select> or <input> */}
|
|
532
|
+
{/* <div>
|
|
533
|
+
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
534
|
+
{t('{module}:form.department', 'Department')}
|
|
535
|
+
</label>
|
|
536
|
+
<EntityLookup
|
|
537
|
+
apiEndpoint="/api/{app}/{module}/departments"
|
|
538
|
+
value={formData.departmentId}
|
|
539
|
+
onChange={(id) => setFormData(prev => ({ ...prev, departmentId: id }))}
|
|
540
|
+
mapOption={(dept) => ({ label: dept.name, value: dept.id })}
|
|
541
|
+
placeholder={t('{module}:form.selectDepartment', 'Select a department...')}
|
|
542
|
+
/>
|
|
543
|
+
</div> */}
|
|
544
|
+
|
|
545
|
+
{/* Actions */}
|
|
546
|
+
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
|
|
547
|
+
<button
|
|
548
|
+
type="button"
|
|
549
|
+
onClick={() => navigate(-1)}
|
|
550
|
+
className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]"
|
|
551
|
+
>
|
|
552
|
+
{t('common:actions.cancel', 'Cancel')}
|
|
553
|
+
</button>
|
|
554
|
+
<button
|
|
555
|
+
type="submit"
|
|
556
|
+
disabled={submitting}
|
|
557
|
+
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"
|
|
558
|
+
>
|
|
559
|
+
{submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
|
|
560
|
+
</button>
|
|
561
|
+
</div>
|
|
562
|
+
</form>
|
|
477
563
|
</div>
|
|
478
564
|
);
|
|
479
565
|
}
|
|
@@ -485,21 +571,23 @@ export function EntityCreatePage() {
|
|
|
485
571
|
import { useState, useEffect, useCallback } from 'react';
|
|
486
572
|
import { useTranslation } from 'react-i18next';
|
|
487
573
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
488
|
-
import { Loader2 } from 'lucide-react';
|
|
574
|
+
import { Loader2, ArrowLeft } from 'lucide-react';
|
|
575
|
+
// For FK Guid fields: import { EntityLookup } from '@/components/ui/EntityLookup';
|
|
489
576
|
|
|
490
577
|
export function EntityEditPage() {
|
|
491
578
|
const { entityId } = useParams<{ entityId: string }>();
|
|
492
579
|
const { t } = useTranslation(['{module}']);
|
|
493
580
|
const navigate = useNavigate();
|
|
494
|
-
const [
|
|
581
|
+
const [formData, setFormData] = useState<UpdateEntityDto | null>(null);
|
|
495
582
|
const [loading, setLoading] = useState(true);
|
|
496
583
|
const [submitting, setSubmitting] = useState(false);
|
|
584
|
+
const [error, setError] = useState<string | null>(null);
|
|
497
585
|
|
|
498
586
|
const loadEntity = useCallback(async () => {
|
|
499
587
|
try {
|
|
500
588
|
setLoading(true);
|
|
501
589
|
const result = await entityApi.getById(entityId!);
|
|
502
|
-
|
|
590
|
+
setFormData(result);
|
|
503
591
|
} catch {
|
|
504
592
|
navigate(-1);
|
|
505
593
|
} finally {
|
|
@@ -509,7 +597,7 @@ export function EntityEditPage() {
|
|
|
509
597
|
|
|
510
598
|
useEffect(() => { loadEntity(); }, [loadEntity]);
|
|
511
599
|
|
|
512
|
-
if (loading) {
|
|
600
|
+
if (loading || !formData) {
|
|
513
601
|
return (
|
|
514
602
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
515
603
|
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-accent-500)]" />
|
|
@@ -517,13 +605,15 @@ export function EntityEditPage() {
|
|
|
517
605
|
);
|
|
518
606
|
}
|
|
519
607
|
|
|
520
|
-
const handleSubmit = async (
|
|
608
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
609
|
+
e.preventDefault();
|
|
521
610
|
try {
|
|
522
611
|
setSubmitting(true);
|
|
523
|
-
|
|
612
|
+
setError(null);
|
|
613
|
+
await entityApi.update(entityId!, formData);
|
|
524
614
|
navigate(-1); // Back to detail or list
|
|
525
615
|
} catch (err: any) {
|
|
526
|
-
|
|
616
|
+
setError(err.message || t('{module}:errors.updateFailed', 'Update failed'));
|
|
527
617
|
} finally {
|
|
528
618
|
setSubmitting(false);
|
|
529
619
|
}
|
|
@@ -534,8 +624,9 @@ export function EntityEditPage() {
|
|
|
534
624
|
{/* Back button */}
|
|
535
625
|
<button
|
|
536
626
|
onClick={() => navigate(-1)}
|
|
537
|
-
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
627
|
+
className="flex items-center gap-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
538
628
|
>
|
|
629
|
+
<ArrowLeft className="w-4 h-4" />
|
|
539
630
|
{t('common:actions.back', 'Back')}
|
|
540
631
|
</button>
|
|
541
632
|
|
|
@@ -544,14 +635,61 @@ export function EntityEditPage() {
|
|
|
544
635
|
{t('{module}:actions.edit', 'Edit {Entity}')}
|
|
545
636
|
</h1>
|
|
546
637
|
|
|
547
|
-
{/*
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
638
|
+
{/* Error state */}
|
|
639
|
+
{error && (
|
|
640
|
+
<div className="p-4 bg-[var(--error-bg)] border border-[var(--error-border)] rounded-[var(--radius-card)]">
|
|
641
|
+
<span className="text-[var(--error-text)]">{error}</span>
|
|
642
|
+
</div>
|
|
643
|
+
)}
|
|
644
|
+
|
|
645
|
+
{/* Form pre-filled — NEVER in a modal */}
|
|
646
|
+
<form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
|
|
647
|
+
{/* Text field (pre-filled) */}
|
|
648
|
+
<div>
|
|
649
|
+
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
650
|
+
{t('{module}:form.name', 'Name')}
|
|
651
|
+
</label>
|
|
652
|
+
<input
|
|
653
|
+
type="text"
|
|
654
|
+
value={formData.name}
|
|
655
|
+
onChange={(e) => setFormData(prev => prev ? { ...prev, name: e.target.value } : prev)}
|
|
656
|
+
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)]"
|
|
657
|
+
required
|
|
658
|
+
/>
|
|
659
|
+
</div>
|
|
660
|
+
|
|
661
|
+
{/* FK Guid field — ALWAYS use EntityLookup, NEVER <select> or <input> */}
|
|
662
|
+
{/* <div>
|
|
663
|
+
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
664
|
+
{t('{module}:form.department', 'Department')}
|
|
665
|
+
</label>
|
|
666
|
+
<EntityLookup
|
|
667
|
+
apiEndpoint="/api/{app}/{module}/departments"
|
|
668
|
+
value={formData.departmentId}
|
|
669
|
+
onChange={(id) => setFormData(prev => prev ? { ...prev, departmentId: id } : prev)}
|
|
670
|
+
mapOption={(dept) => ({ label: dept.name, value: dept.id })}
|
|
671
|
+
placeholder={t('{module}:form.selectDepartment', 'Select a department...')}
|
|
672
|
+
/>
|
|
673
|
+
</div> */}
|
|
674
|
+
|
|
675
|
+
{/* Actions */}
|
|
676
|
+
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
|
|
677
|
+
<button
|
|
678
|
+
type="button"
|
|
679
|
+
onClick={() => navigate(-1)}
|
|
680
|
+
className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]"
|
|
681
|
+
>
|
|
682
|
+
{t('common:actions.cancel', 'Cancel')}
|
|
683
|
+
</button>
|
|
684
|
+
<button
|
|
685
|
+
type="submit"
|
|
686
|
+
disabled={submitting}
|
|
687
|
+
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"
|
|
688
|
+
>
|
|
689
|
+
{submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
|
|
690
|
+
</button>
|
|
691
|
+
</div>
|
|
692
|
+
</form>
|
|
555
693
|
</div>
|
|
556
694
|
);
|
|
557
695
|
}
|
|
@@ -636,7 +774,7 @@ const [showCreateModal, setShowCreateModal] = useState(false);
|
|
|
636
774
|
<Dialog open={editDialogOpen}><EditForm entity={selected} /></Dialog>
|
|
637
775
|
|
|
638
776
|
// WRONG: drawer for form
|
|
639
|
-
<Drawer open={isDrawerOpen}><
|
|
777
|
+
<Drawer open={isDrawerOpen}><form>...</form></Drawer>
|
|
640
778
|
|
|
641
779
|
// WRONG: inline form toggle
|
|
642
780
|
{isEditing ? <EditForm /> : <DetailView />}
|
|
@@ -685,24 +823,25 @@ style={{ backgroundColor: '#ffffff', color: '#1a1a1a' }}
|
|
|
685
823
|
|
|
686
824
|
## 5. Component Rules
|
|
687
825
|
|
|
688
|
-
| Need | Component | Source |
|
|
689
|
-
|
|
690
|
-
| Data table | `
|
|
691
|
-
|
|
|
692
|
-
|
|
|
693
|
-
|
|
|
694
|
-
|
|
|
695
|
-
|
|
|
696
|
-
|
|
|
697
|
-
|
|
|
826
|
+
| Need | Component | Source | Notes |
|
|
827
|
+
|------|-----------|--------|-------|
|
|
828
|
+
| Data table | `DataTable` | `@/components/ui/DataTable` | Shared component (sorting, pagination, search) |
|
|
829
|
+
| Entity cards | `EntityCard` | `@/components/ui/EntityCard` | Shared component (avatar, badges, actions) |
|
|
830
|
+
| FK field lookup | `EntityLookup` | Generate in `@/components/ui/EntityLookup` | See section 6 for full pattern |
|
|
831
|
+
| KPI statistics | `StatCard` | Generate locally per dashboard | See dashboard-chart.md pattern |
|
|
832
|
+
| Chart wrapper | `ChartCard` | Generate locally per dashboard | See dashboard-chart.md pattern |
|
|
833
|
+
| Loading spinner | `Loader2` | `lucide-react` | Shared |
|
|
834
|
+
| Page loader | `PageLoader` | `@/components/ui/PageLoader` | Shared (Suspense fallback) |
|
|
835
|
+
| Docs toggle | `DocToggleButton` | `@/components/docs/DocToggleButton` | Shared |
|
|
698
836
|
|
|
699
837
|
### Rules
|
|
700
838
|
|
|
701
|
-
- **NEVER** use raw `<table>` — use
|
|
839
|
+
- **NEVER** use raw `<table>` — use `DataTable` from `@/components/ui/DataTable`
|
|
702
840
|
- **NEVER** create custom spinners — use `Loader2` from lucide-react
|
|
703
841
|
- **NEVER** import axios directly — use `@/services/api/apiClient`
|
|
704
842
|
- **ALWAYS** use `PageLoader` as Suspense fallback
|
|
705
843
|
- **ALWAYS** use existing shared components before creating new ones
|
|
844
|
+
- **ALWAYS** use `EntityLookup` for FK Guid fields (never `<select>` or `<input>` for GUIDs)
|
|
706
845
|
|
|
707
846
|
---
|
|
708
847
|
|
|
@@ -1183,7 +1322,7 @@ Before marking frontend tasks as complete, verify:
|
|
|
1183
1322
|
- [ ] Pages follow loading → error → content pattern
|
|
1184
1323
|
- [ ] Pages use `src/pages/{App}/{Module}/` hierarchy
|
|
1185
1324
|
- [ ] API calls use generated hooks or `apiClient` (never raw axios)
|
|
1186
|
-
- [ ] Components use
|
|
1325
|
+
- [ ] Components use DataTable/EntityCard (never raw HTML `<table>`)
|
|
1187
1326
|
- [ ] **FK fields use `EntityLookup` — ZERO plain text inputs for Guid FK fields**
|
|
1188
1327
|
- [ ] **All FK fields have `mapOption` showing display name, not GUID**
|
|
1189
1328
|
- [ ] **Backend APIs support `?search=` query parameter for EntityLookup**
|
|
@@ -1232,44 +1371,55 @@ export function EntityCreatePage() {
|
|
|
1232
1371
|
<div className="space-y-6">
|
|
1233
1372
|
{/* ... form header ... */}
|
|
1234
1373
|
|
|
1235
|
-
<
|
|
1236
|
-
{
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1374
|
+
<form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
|
|
1375
|
+
{/* Name field */}
|
|
1376
|
+
<div>
|
|
1377
|
+
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
1378
|
+
{t('{module}:form.name', 'Name')}
|
|
1379
|
+
</label>
|
|
1380
|
+
<input
|
|
1381
|
+
type="text"
|
|
1382
|
+
value={formData.name}
|
|
1383
|
+
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
|
1384
|
+
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)]"
|
|
1385
|
+
required
|
|
1386
|
+
/>
|
|
1387
|
+
</div>
|
|
1388
|
+
|
|
1389
|
+
{/* Scope selector — binary toggle for optional entities */}
|
|
1390
|
+
<div className="space-y-2">
|
|
1391
|
+
<label className="block text-sm font-medium text-[var(--text-primary)]">
|
|
1392
|
+
{t('common:scope', 'Scope')}
|
|
1393
|
+
</label>
|
|
1394
|
+
<select
|
|
1395
|
+
value={formData.isShared ? 'shared' : 'tenant'}
|
|
1396
|
+
onChange={(e) => handleScopeChange(e.target.value)}
|
|
1397
|
+
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)] text-[var(--text-primary)]"
|
|
1398
|
+
>
|
|
1399
|
+
<option value="tenant">
|
|
1400
|
+
{t('common:scope.tenant', 'My Organization')}
|
|
1401
|
+
</option>
|
|
1402
|
+
<option value="shared">
|
|
1403
|
+
{t('common:scope.shared', 'Shared (All Organizations)')}
|
|
1404
|
+
</option>
|
|
1405
|
+
</select>
|
|
1406
|
+
<p className="text-xs text-[var(--text-secondary)]">
|
|
1407
|
+
{formData.isShared
|
|
1408
|
+
? t('common:scope.shared.hint', 'This data will be accessible to all organizations')
|
|
1409
|
+
: t('common:scope.tenant.hint', 'This data will only be visible to your organization')}
|
|
1410
|
+
</p>
|
|
1411
|
+
</div>
|
|
1412
|
+
|
|
1413
|
+
{/* Actions */}
|
|
1414
|
+
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
|
|
1415
|
+
<button type="button" onClick={() => navigate(-1)} className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]">
|
|
1416
|
+
{t('common:actions.cancel', 'Cancel')}
|
|
1417
|
+
</button>
|
|
1418
|
+
<button type="submit" disabled={submitting} className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)] disabled:opacity-50">
|
|
1419
|
+
{submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
|
|
1420
|
+
</button>
|
|
1421
|
+
</div>
|
|
1422
|
+
</form>
|
|
1273
1423
|
</div>
|
|
1274
1424
|
);
|
|
1275
1425
|
}
|
|
@@ -1288,27 +1438,51 @@ export function EntityCreatePage() {
|
|
|
1288
1438
|
});
|
|
1289
1439
|
|
|
1290
1440
|
return (
|
|
1291
|
-
<
|
|
1292
|
-
{
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1441
|
+
<form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
|
|
1442
|
+
{/* Name field */}
|
|
1443
|
+
<div>
|
|
1444
|
+
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
1445
|
+
{t('{module}:form.name', 'Name')}
|
|
1446
|
+
</label>
|
|
1447
|
+
<input
|
|
1448
|
+
type="text"
|
|
1449
|
+
value={formData.name}
|
|
1450
|
+
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
|
1451
|
+
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)]"
|
|
1452
|
+
required
|
|
1453
|
+
/>
|
|
1454
|
+
</div>
|
|
1455
|
+
|
|
1456
|
+
{/* Scope selector — enum values for scoped entities */}
|
|
1457
|
+
<div>
|
|
1458
|
+
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
1459
|
+
{t('common:scope', 'Scope')}
|
|
1460
|
+
</label>
|
|
1461
|
+
<select
|
|
1462
|
+
value={formData.scope}
|
|
1463
|
+
onChange={(e) => setFormData(prev => ({ ...prev, scope: e.target.value }))}
|
|
1464
|
+
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)] text-[var(--text-primary)]"
|
|
1465
|
+
required
|
|
1466
|
+
>
|
|
1467
|
+
<option value="Tenant">{t('common:scope.tenant', 'My Organization')}</option>
|
|
1468
|
+
<option value="Shared">{t('common:scope.shared', 'Shared')}</option>
|
|
1469
|
+
<option value="Platform">{t('common:scope.platform', 'Platform (Admin Only)')}</option>
|
|
1470
|
+
</select>
|
|
1471
|
+
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
|
1472
|
+
{t('common:scope.help', 'Select the visibility level for this data')}
|
|
1473
|
+
</p>
|
|
1474
|
+
</div>
|
|
1475
|
+
|
|
1476
|
+
{/* Actions */}
|
|
1477
|
+
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
|
|
1478
|
+
<button type="button" onClick={() => navigate(-1)} className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]">
|
|
1479
|
+
{t('common:actions.cancel', 'Cancel')}
|
|
1480
|
+
</button>
|
|
1481
|
+
<button type="submit" disabled={submitting} className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)] disabled:opacity-50">
|
|
1482
|
+
{submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
|
|
1483
|
+
</button>
|
|
1484
|
+
</div>
|
|
1485
|
+
</form>
|
|
1312
1486
|
);
|
|
1313
1487
|
}
|
|
1314
1488
|
```
|
|
@@ -1382,7 +1556,7 @@ export function ScopeBadge({ tenantId, scope }: ScopeBadgeProps) {
|
|
|
1382
1556
|
}
|
|
1383
1557
|
```
|
|
1384
1558
|
|
|
1385
|
-
### Using ScopeBadge in
|
|
1559
|
+
### Using ScopeBadge in DataTable Columns
|
|
1386
1560
|
|
|
1387
1561
|
```tsx
|
|
1388
1562
|
// In the list page, add a scope column
|
|
@@ -1403,10 +1577,11 @@ const columns = [
|
|
|
1403
1577
|
];
|
|
1404
1578
|
|
|
1405
1579
|
return (
|
|
1406
|
-
<
|
|
1580
|
+
<DataTable
|
|
1407
1581
|
columns={columns}
|
|
1408
1582
|
data={data}
|
|
1409
|
-
|
|
1583
|
+
searchable
|
|
1584
|
+
pagination={{ pageSize: 10 }}
|
|
1410
1585
|
onRowClick={(row) => navigate(`${row.id}`)}
|
|
1411
1586
|
/>
|
|
1412
1587
|
);
|
|
@@ -315,7 +315,7 @@ const [loading, setLoading] = useState(true);
|
|
|
315
315
|
|
|
316
316
|
### Components & CSS
|
|
317
317
|
|
|
318
|
-
**Components:**
|
|
318
|
+
**Components:** DataTable, EntityCard, EntityLookup (NEVER raw HTML `<table>`)
|
|
319
319
|
**CSS:** Variables ONLY — hardcoded Tailwind colors are **BLOCKING** in POST-CHECK 9:
|
|
320
320
|
|
|
321
321
|
| Instead of | Use |
|
|
@@ -377,7 +377,7 @@ See `references/smartstack-frontend.md` section 6 for the full `EntityLookup` co
|
|
|
377
377
|
**FORBIDDEN:**
|
|
378
378
|
- `src/pages/{Module}/` (flat, missing App)
|
|
379
379
|
- `import axios` → use `@/services/api/apiClient`
|
|
380
|
-
- `<table>` → use
|
|
380
|
+
- `<table>` → use DataTable
|
|
381
381
|
- `<input type="text">` for FK Guid fields → use `EntityLookup`
|
|
382
382
|
- `<select>` for FK Guid fields → use `EntityLookup` (even with API-loaded options)
|
|
383
383
|
- Placeholder "Enter ID" or "Enter GUID" → use `EntityLookup`
|
|
@@ -299,12 +299,22 @@ test({module}): backend unit and integration tests
|
|
|
299
299
|
For each module:
|
|
300
300
|
- **API client:** MCP scaffold_api_client
|
|
301
301
|
- **Routes:** MCP scaffold_routes (outputFormat: 'clientRoutes') → generates lazy imports + Suspense
|
|
302
|
+
- **Wire Routes to App.tsx (BLOCKING):** After scaffold_routes, routes MUST be wired into App.tsx:
|
|
303
|
+
→ Read App.tsx and detect the routing pattern
|
|
304
|
+
→ **Pattern A** (`applicationRoutes: ApplicationRouteExtensions`): add routes to `applicationRoutes['{application_kebab}'][]` with RELATIVE paths
|
|
305
|
+
→ **Pattern B** (JSX `<Route>`): nest routes inside `<Route path="/{application}" element={<AppLayout />}>` + duplicate in tenant block
|
|
306
|
+
→ **FORBIDDEN:** Adding business routes to `clientRoutes[]` — it is ONLY for non-app routes (`/about`, `/pricing`)
|
|
307
|
+
→ ALL business applications use `<AppLayout />` as layout wrapper
|
|
308
|
+
→ See `references/frontend-route-wiring-app-tsx.md` for full Pattern A/B detection and examples
|
|
309
|
+
→ Verify: `mcp__smartstack__validate_frontend_routes (scope: 'routes')`
|
|
302
310
|
- **Pages:** use /ui-components skill — MUST follow smartstack-frontend.md patterns:
|
|
303
311
|
→ React.lazy() for all page imports (named export wrapping)
|
|
304
312
|
→ `<Suspense fallback={<PageLoader />}>` around all lazy components
|
|
305
313
|
→ Page structure: hooks → useEffect(load) → loading → error → content
|
|
306
314
|
→ CSS variables only: bg-[var(--bg-card)], text-[var(--text-primary)]
|
|
307
|
-
→
|
|
315
|
+
→ DataTable/EntityCard (NEVER raw HTML `<table>`)
|
|
316
|
+
→ If entity has FK Guid fields: generate EntityLookup component in @/components/ui/EntityLookup (see smartstack-frontend.md section 6)
|
|
317
|
+
→ Dashboard pages: generate StatCard + ChartCard locally (see ui-components patterns/dashboard-chart.md)
|
|
308
318
|
- **FORM PAGES (CRITICAL):** Create/Edit forms are FULL PAGES with own routes:
|
|
309
319
|
→ EntityCreatePage.tsx with route /{module}/create
|
|
310
320
|
→ EntityEditPage.tsx with route /{module}/:id/edit
|
|
@@ -368,6 +378,13 @@ For each entity:
|
|
|
368
378
|
**MANDATORY: Read references/smartstack-frontend.md FIRST**
|
|
369
379
|
- API client: MCP scaffold_api_client
|
|
370
380
|
- Routes: MCP scaffold_routes (outputFormat: 'clientRoutes')
|
|
381
|
+
- Wire Routes to App.tsx (BLOCKING): After scaffold_routes, wire into App.tsx:
|
|
382
|
+
→ Read App.tsx, detect Pattern A (applicationRoutes) or Pattern B (JSX Route)
|
|
383
|
+
→ Pattern A: add to applicationRoutes['{application_kebab}'][] with RELATIVE paths
|
|
384
|
+
→ Pattern B: nest inside <Route path='/{application}' element={<AppLayout />}> + tenant block
|
|
385
|
+
→ FORBIDDEN: Adding business routes to clientRoutes[] (ONLY for /about, /pricing)
|
|
386
|
+
→ See references/frontend-route-wiring-app-tsx.md for full patterns
|
|
387
|
+
→ Verify: mcp__smartstack__validate_frontend_routes (scope: 'routes')
|
|
371
388
|
- Pages: /ui-components skill (ALL 4 types: List, Detail, Create, Edit)
|
|
372
389
|
- I18n: 4 JSON files (fr, en, it, de)
|
|
373
390
|
- FORM PAGES: Full pages with own routes (ZERO modals)
|
|
@@ -57,7 +57,7 @@ For each changed file, check:
|
|
|
57
57
|
- [ ] Random GUIDs in seed data via Guid.NewGuid() (no deterministic/sequential/fixed)
|
|
58
58
|
- [ ] 4 languages in translations
|
|
59
59
|
- [ ] CSS variables (not hardcoded colors)
|
|
60
|
-
- [ ]
|
|
60
|
+
- [ ] DataTable (not raw HTML `<table>`) and concrete form markup (not non-existent SmartForm)
|
|
61
61
|
- [ ] Correct Layout wrapper per application
|
|
62
62
|
|
|
63
63
|
**FK Fields & Forms:**
|
|
@@ -77,7 +77,7 @@ function transformPrdJsonToRalphV2(prdJson, moduleCode) {
|
|
|
77
77
|
id: taskId,
|
|
78
78
|
description: `[frontend] Generate COMPLETE frontend for module ${moduleCode} via MCP (${frontendFiles.length} files)`,
|
|
79
79
|
status: "pending", category: "frontend", dependencies: apiDepId ? [apiDepId] : [],
|
|
80
|
-
acceptance_criteria: `Pages in src/pages/${app}/${moduleCode}/; MCP scaffold_api_client + scaffold_routes;
|
|
80
|
+
acceptance_criteria: `Pages in src/pages/${app}/${moduleCode}/; MCP scaffold_api_client + scaffold_routes; DataTable for lists; CSS variables ONLY; 4-language i18n; npm run typecheck passes`,
|
|
81
81
|
started_at: null, completed_at: null, iteration: null, commit_hash: null,
|
|
82
82
|
files_changed: { created: frontendFiles.map(f => f.path), modified: [] },
|
|
83
83
|
validation: null, error: null, module: moduleCode,
|
|
@@ -136,7 +136,7 @@ function transformPrdJsonToRalphV2(prdJson, moduleCode) {
|
|
|
136
136
|
tasks.push({ id: taskId,
|
|
137
137
|
description: `[frontend] Generate COMPLETE frontend for ${moduleCode} via MCP (${derivedFiles.length} files)`,
|
|
138
138
|
status: "pending", category: "frontend", dependencies: [lastIdByCategory["api"] || lastIdByCategory["application"]].filter(Boolean),
|
|
139
|
-
acceptance_criteria: `Pages in src/pages/${app}/${moduleCode}/;
|
|
139
|
+
acceptance_criteria: `Pages in src/pages/${app}/${moduleCode}/; DataTable for lists; CSS variables ONLY; 4-language i18n; npm run typecheck passes`,
|
|
140
140
|
started_at: null, completed_at: null, iteration: null, commit_hash: null,
|
|
141
141
|
files_changed: { created: derivedFiles.map(f => f.path), modified: [] },
|
|
142
142
|
validation: null, error: null, module: moduleCode,
|