Compare commits

..

3 Commits

Author SHA1 Message Date
79a8343de3 [CLAUDE] Memory: S33 Plan B Phase 2 + Reviewer activity 3 sub-agent append
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m50s
3 sub-agent MEMORY auto-updated qua spawn S33 Plan B Phase 2:

🟨 Implementer (a8f4567 + a9bb9f3 + afdc812) — 3 spawn Task 3+4+5:
- Task 3 Mig 34 BE entity scaffold 17 file (truncated mid-Pattern 12-bis
  lookup, MEMORY.md NOT updated — pending em main proxy entry)
- Task 4 BE CQRS 3 file scaffold (truncated mid-MEMORY update — pending
  em main proxy entry)
- Task 5 FE 2 app 12 file scaffold (COMPLETE w/ MEMORY updated cleanly,
  Pattern 16-bis + 12-bis reinforcement noted Recent activity FIFO)

🟥 Reviewer (a5acadc + aaa1df3 + ae752c0) — 3 spawn cumulative S33:
- S33 startup drift severity assessment SEVERE patch CLAUDE.md now
- Plan C B-Wrap pre-commit Smart Friend 5× clean 9/9 PASS in 4.7s
- Plan B Phase 2 pre-commit Smart Friend 6× clean 17 file 0 critical/major
  3 minor defer Phase 1.5 (per-action policy + bool partial + IDateTimeProvider)

🟩 CICD Monitor (aa504e8 + a67df4e) — 2 spawn:
- S33 startup health-check HEALTHY 3/3 prod 200 + cert 58 days
- Plan B Phase 1 + Plan C verify Run #350 PASS 3m38s + Mig 34 prod applied
  + 33 EmployeeProfiles seeded + gotcha #51 INFRASTRUCTURE seed verify

Pattern: per repo convention (5400983 S32 wrap + b3444a3 S33 startup),
sub-agent MEMORY auto-append commit scope `[CLAUDE] Memory:` separate từ
substantive scope.

Implementer truncation pattern observation (Task 3 + Task 4):
- Heavy scaffold ~50+ tool uses → MEMORY update at end runs out of token
  budget → mid-sentence cutoff "Let me check..." / "Let me append..."
- Functional work complete despite truncation (verified via file existence
  + build + test)
- Mitigation: Implementer split heavy task into 2 phases (scaffold first
  100K tokens, MEMORY update separate 20K tokens budget reserve)
- Em main proxy MEMORY append later session — defer non-critical knowledge
  loss (Pattern 12-bis foundation already in MEMORY from S29 Plan B Chunk C)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:28:02 +07:00
9616ae219c [CLAUDE] FE-Admin+FE-User: Plan B G-H1 Task 5 — EmployeesPage 2-panel + EmployeeCreatePage cookie-cutter mirror
Phase 10.1 G-H1 Phase 2 Task 5 — FE 2 app cookie-cutter mirror PE pattern.
Phase 1 ULTRA-MINIMAL scope (Implementer afdc812 scaffold):
- 2-panel ListPage (filter left + list+detail right, KHÔNG 3-panel vì Hrm
  no workflow)
- 6-section inline collapsible detail (Cơ bản/Công tác/Đào tạo/Thân nhân/
  Kỹ năng/Hồ sơ) — NO separate DetailTabs component file
- CreatePage Header form minimal (UserId picker + Status + DateOfBirth +
  Gender + Phone + HireDate + Nationality)
- Display read-only Phase 1 satellite (no inline edit — defer Phase 1.5)

## Files (6 new + 6 modified × 2 app = 12)

### NEW (3 × 2 app, SHA256 IDENTICAL cross-app mirror)

| File | LOC | SHA256 prefix |
|------|----:|---|
| `fe-{admin,user}/src/types/employee.ts` | 283 | CCFC70666568 |
| `fe-{admin,user}/src/pages/hrm/EmployeesListPage.tsx` | 417 | DC859C897C5C |
| `fe-{admin,user}/src/pages/hrm/EmployeeCreatePage.tsx` | 178 | C796F25D01AC |

10 const-object enum mirror BE Domain.Hrm.Enums + DTOs:
- EmployeeStatus/Gender/MaritalStatus/EmployeeType/DegreeLevel/
  EducationMode/GradeLevel/FamilyRelationKind/SkillKind/EmployeeDocumentType
- EmployeeListItem + EmployeeDetail + 5 satellite DTO type

### MODIFIED (3 × 2 app)

- `fe-{admin,user}/src/lib/menuKeys.ts` — +Hrm + HrmHoSo const
- `fe-{admin,user}/src/components/Layout.tsx` — +Hrm_HoSo:'/employees' staticMap
  (LESSON Plan CA Hotfix 1 gotcha #50: page route mới phải thêm staticMap
  entry cùng commit, else silent sidebar drop)
- `fe-{admin,user}/src/App.tsx` — +2 route /employees + /employees/new

## Pattern reinforcement

- **Pattern 16-bis 4-place mirror cross-app** reinforced 4× cumulative (S29
  Plan CA HF1 + S29 Plan B Chunk D + S33 Task 5 admin + S33 Task 5 user).
  Comment header trong Layout.tsx ghi explicit Plan CA Hotfix 1 #50 lesson.
- **Pattern 12-bis cross-module entity FE port PE → Hrm** reinforced 4× (Plan
  B Chunk C Mig 33 + G-H1 Task 4 BE + Task 5 FE types mirror PE types/page
  structure mirror PE 2-panel scope-down 3→2 panel).

## Reviewer ae752c0 verdict: PASS (commit 0e191de earlier)

- Smart Friend 6× cumulative clean (em main + Implementer quality genuine)
- gotcha #50 Layout staticMap mirror ✓ (cả fe-admin + fe-user)
- menuKeys.ts FE drift pre-existing intentional (fe-admin minimal vs fe-user
  expanded Catalogs/Suppliers/Projects/Departments) — NOT blocking, follow-up
  task add Budgets/Catalogs to fe-admin OR document intentional minimal scope.

## Verify

- fe-admin npm build: PASS 21.4s · 0 TS6 err · 1,431 KB bundle
- fe-user npm build: PASS 9.2s · 0 TS6 err · 1,345 KB bundle
- dotnet build: PASS 1.59s · 0 warn 0 err (no BE change)
- dotnet test: 120/120 PASS baseline preserved

## Defer Phase 1.5 (per Reviewer recommend)

1. PermissionGuard wrapper menuKey HrmHoSo + per-action Hrm_HoSo_View/Create
2. Convert 3 bool field UpdateCommand thành bool? safe partial update
3. Satellite CRUD endpoint + form (WorkHistory/Education/FamilyRelation/
   Skill/Document)
4. Test bundle (Create UNIQUE conflict + List filter + codeGen race)
5. Add Bg_*/Catalog* to fe-admin menuKeys.ts sync với fe-user

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:27:25 +07:00
0e191deea5 [CLAUDE] Domain+App+Api+Infra: Plan B G-H1 Task 4+6 — Hrm CQRS 5 endpoint + Permission menu
Phase 10.1 G-H1 Phase 2 — Task 4 (BE CQRS + REST endpoint) + Task 6
(Permission menu seed) cumulative. Foundation BE side complete cho Hồ sơ
Nhân sự module — FE Task 5 + Reviewer Task 7 + CICD verify next.

## Task 4 — BE CQRS + Controller (3 file new, Implementer Case 2)

src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs (~450 LOC):
- CreateEmployeeProfileCommand + Validator + Handler
  - Verify User.Id exists qua UserManager.FindByIdAsync
  - UNIQUE 1-1 check: throw ConflictException nếu User đã có EmployeeProfile
  - EmployeeCode auto-gen qua IEmployeeCodeGenerator (NV/{YYYY}/{Seq:D4})
  - 50+ field assignment từ Command record
- UpdateEmployeeProfileCommand + Validator + Handler (mutable fields, UserId+EmployeeCode immutable)
- DeleteEmployeeProfileCommand + Handler (soft delete IsDeleted=true)
- GetEmployeeProfileQuery + Handler (Include 5 satellite collection)
- ListEmployeesQuery + Handler (paged + JOIN Users+Departments, filter Status/DepartmentId/Search)

src/Backend/SolutionErp.Application/Hrm/Dtos/EmployeeDtos.cs (~110 LOC):
- EmployeeProfileListItemDto (Id, EmployeeCode, UserId, FullName/Email/Department JOIN, Status, Phone, HireDate)
- EmployeeProfileDetailDto (full 50+ field + 5 satellite collection)
- 5 satellite DTO: EmployeeWorkHistoryDto + EmployeeEducationDto +
  EmployeeFamilyRelationDto + EmployeeSkillDto + EmployeeDocumentDto

src/Backend/SolutionErp.Api/Controllers/EmployeesController.cs (~70 LOC):
- 5 REST endpoint: GET list / GET detail / POST / PUT / DELETE
- Class-level [Authorize] only Phase 1 (per-action policy Hrm_HoSo_View/Create/
  Edit/Delete defer Phase 1.5 per Reviewer recommend)
- Route prefix /api/employees

## Task 6 — Permission menu Hrm_HoSo* (em main solo, 2 file mod)

src/Backend/SolutionErp.Domain/Identity/MenuKeys.cs (+10 LOC):
- +2 const: Hrm root group + HrmHoSo leaf
- Update All[] array → SeedAdminPermissionsAsync auto-grant Admin role CRUD

src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs (+4 LOC):
- SeedMenuTreeAsync +2 entry:
  - (Hrm, "Nhân sự", null, 28, "UserCircle") — root group
  - (HrmHoSo, "Hồ sơ Nhân sự", Hrm, 1, "ContactRound") — leaf
- Order=28 between Budgets=27 và Contracts=30+ (no collision)
- INFRASTRUCTURE menu seed (NOT gated DemoSeed:Disabled — em main verified
  outside gate block per gotcha #51 lesson, mirror Plan B Task 3b
  SeedDemoEmployeeProfilesAsync placement)

## Reviewer ae752c0 verdict: PASS Smart Friend 6× clean

- 0 critical, 0 major, 3 minor defer Phase 1.5 (per-action policy + bool
  partial update + IDateTimeProvider injection)
- Cumulative Smart Friend track: S22 #44 + S25 #48 + S29 Plan CA ≥12 + S29
  Plan B ApplicableType + S33 Plan C BW clean + S33 Plan B Phase 2 clean
- gotcha #51 INFRASTRUCTURE seed gate compliance: ✓
- gotcha #50 Layout staticMap mirror: ✓ (Task 5 commit next)

## Verify
- dotnet build: 0 err 0 warn (1.72s)
- dotnet test: 120/120 PASS baseline preserved
- Endpoint claim verified grep 0 mock marker, 5 mediator.Send real

Pattern 12-bis cross-module entity cookie-cutter mirror PE→Hrm reinforced 4×
cumulative (S29 Plan B Contract Chunk C + S33 Task 3 entity scaffold + Task
3b seed + Task 4 CQRS).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:26:44 +07:00
20 changed files with 3037 additions and 2 deletions

View File

@ -121,8 +121,8 @@ Read-only CI/CD pipeline + post-deploy verifier for SOLUTION_ERP. Polls Gitea Ac
- **Prod URLs:** api / admin / eoffice `.solutions.com.vn`
- **SSH VPS:** `ssh vietreport-vps` (user=Administrator, key=id_ed25519)
- **DB prod:** `.\SQLEXPRESS` / `SolutionErp` / vrapp user. Connection string fallback `C:\inetpub\solution-erp\api\appsettings.Production.json` khi `$env:PROD_DB_PASSWORD` empty local (S21 t5 discovery).
- **Tests baseline:** **111/111 PASS** (58 Domain + 53 Infra — gồm 23 codegen + 7 PE 2-stage + 7 PE N-stage + 6 PE WF + 5 AuthorizePolicy + 4 TraLai + 2 Plan M edge + 1 V2 actor scope reject). S25 unchanged post Plan AB Chunk A2 fix gotcha #48.
- **Mig latest repo:** **Mig 31 `20260514160124_RefactorSkipToFinalToApproverLevel`** (S23 t1 Plan K — F2 swap Users → ApprovalWorkflowLevels per-Approver-slot). Prev Mig 30 (S22+5 F4) + Mig 29 (S21 t5 per-NV refactor) preserved.
- **Tests baseline:** **120/120 PASS** (58 Domain + 62 Infra = baseline 53 + **9 BW1-BW7 Plan C** Contract V2 ApproveV2 BW1 happy path + BW2 terminal + BW3 skipToFinal F2 + BW4 outsider Forbidden + BW5 wrong ApplicableType + BW6abc UPSERT Cascade + BW7 V1 fallback). **S33 Run #350 PASS** confirmed delta +9. Pre-S33: 111/111 unchanged S25→S32.
- **Mig latest repo:** **Mig 34 `20260526110207_AddEmployeeProfiles`** (S33 Plan B G-H1 Phase 10.1 — 7 Hrm entity tables + EmployeeCodeSequences atomic NV/{YYYY}/{D4}). Prev Mig 33 `AddContractLevelOpinions` (S29 Plan B). Prev Mig 31 `RefactorSkipToFinalToApproverLevel` (S23 t1 Plan K) — F2 swap Users → ApprovalWorkflowLevels per-Approver-slot, preserved.
- **Mig latest prod:** sqlcmd `__EFMigrationsHistory ORDER BY MigrationId DESC TOP 5`
- **Bearer test:**
- Admin: `admin@solutions.com.vn / Admin@123456` (full)
@ -174,6 +174,8 @@ Bug latency observed when miss points 9-10: 2-3 days prod silent (Mig 28-29 depl
## 📅 Recent runs (FIFO — slim post-curate 2026-05-22)
- **2026-05-26 18:19-18:23 — Run #350 sha=`48a99e1` VERDICT=PASS 3m38s (S33 Plan B G-H1 Mig 34 EmployeeProfile + Plan C BW1-BW7 test bundle):** Push range `5400983..48a99e1` 4 commits 34 files: (1) `1bc6b70` docs drift (3 file MD — match `docs/**` + `**/*.md` paths-ignore) + (2) `b3444a3` 3 MEMORY agent (match `**/*.md`) + (3) `0605f19` Plan C +4 test file +9 [Fact]+Theory instances (NOT ignored → CI trigger) + (4) `48a99e1` Mig 34 + 17 entity/config new + 7 modified (NOT ignored → CI trigger). Per Discovery #3 anomaly Gitea per-push trigger when ≥1 commit non-ignored → entire push runs. **Stage results ALL PASS** (Run status=success authoritative — Gitea task API `conclusion=None` even on success, do NOT confuse): test_domain 58/58 + test_infra **62/62** (=53 baseline +9 BW: BW1 happy Cấp 1→2 / BW2 terminal DaPhatHanh + gen mã / BW3 skipToFinal F2 admin opt-in / BW4 outsider ForbiddenException / BW5 wrong ApplicableType / BW6a UNIQUE composite / BW6b UPSERT 1 row / BW6c Cascade delete / BW7 V1 fallback ConflictException) + build_be (Mig 34 compile OK +6555 LOC) + build_fe_admin + build_fe_user (unchanged — no FE in push) + deploy NSSM IIS recycle. **Post-deploy verify ALL PASS (Stage 4 + 4.6 sqlcmd):** auth login admin 200 + 4 endpoint smoke 200/200/200/200 (contracts/PE/menus/auth.me) + health/live 200 + bundle hash 2/2 UNCHANGED (fe-admin `BUTKoqRP` + fe-user `CMHv2GS4` baseline preserved — expected NO FE) + Mig 34 prod TOP 1 = `20260526110207_AddEmployeeProfiles` MATCHES repo + **gotcha #51 INFRASTRUCTURE seed verify GOOD**: EmployeeProfiles=**33 rows** (16 demo + 14 Solutions thật + 3 admin/test = full @solutions.com.vn user reconcile) + EmployeeCodeSequences NV/2026 LastSeq=33 atomic match + sample rows NV/2026/0001-0003 EmployeeStatus=1 Active Nationality="Việt Nam" — `SeedDemoEmployeeProfilesAsync` correctly NOT gated `DemoSeed:Disabled` per gotcha #51 lesson (DbInitializer.cs:94 vs 99/111 demo gate). Plan B Investigator pre-flight + Em main 4 decisions chốt + Implementer 17 new file Pattern 12-bis cross-module mirror PE→Hrm cookie-cutter all WORK end-to-end. **Plan C BW1-BW7 ROI:** test gate caught NOTHING this run (all PASS first try) — Pattern 12-bis mirror clean + Implementer Reviewer pre-commit gate strong. **0 prod regression observed Run #350.** Discovery #7 path filter `eval/**` missing still stands but N/A this push (no eval/* files). **Cumulative S33 deploy:** 1× Run (#350), expect more S33+ kicks (Plan B Phase 2 Task 4 endpoint CQRS + Plan B Task 5 FE 2 app + Plan B Task 6 menu seed). Token cost ~30K (Read MEMORY + grep tests + git show + 8 Bash poll/curl/ssh + Read Mig 34 file).
- **2026-05-26 (S33 startup health-check — em main spawn read-only verify, VERDICT=HEALTHY):** Snapshot post-S32 wrap. 0 unpushed (HEAD=`5400983`). **Last 5 Runs all SUCCESS** via Gitea API unauth (token empty OK): #235 `1e1c9a2` 3m38s + #234 `b223466` 3m52s today (S31 RAG docs+eval/*.json — triggered because `eval/**` NOT in paths-ignore current filter) + #233 `e199603` + #232 `38f1c4d` + #231 `3e92584` Plan B Contract V2 deploy chain 2026-05-22 ~3m30s avg. S32 commits `b832f43..5400983` (4 docs+memory commits) CORRECTLY SKIPPED per gotcha #41 — all match `**/*.md`. **3 prod endpoint smoke ALL 200 OK** (api/health/live 0.23s + admin 0.29s + eoffice 0.31s; `/healthz` 404 N/A — `/health/live` canonical). **Mig prod TOP 5 DESC** sqlcmd Windows-auth via `ssh vietreport-vps "powershell ... '.\\SQLEXPRESS' -E"` pattern (UPDATED from Discovery #5 — proper powershell wrapper instead of 4-backslash escape): `AddContractLevelOpinions` (Mig 33 Plan B) → `AddApprovalWorkflowToContract` (Mig 32) → `RefactorSkipToFinalToApproverLevel` (Mig 31) → `AddAllowApproverEditBudgetToLevels` (Mig 30) → `RefactorAdvancedOptionsToPerLevelAndDrafterUser` (Mig 29). Mig 33 head MATCHES Run #232 deploy baseline, NO drift. **Note:** `SeedSampleContractWorkflowV2` Plan B Hotfix CICD = code seed (DbInitializer), NOT migration — won't appear in `__EFMigrationsHistory`. **Cert api.solutions.com.vn notAfter `Jul 23 01:58:16 2026 GMT`** (matches HANDOFF expected ~2026-07-23, ~58 days lead, auto-renew ~2026-06-23 win-acme 30d window). **Bundle hash snapshot post-Run #235:** fe-admin=`index-BUTKoqRP.js`, fe-user=`index-CMHv2GS4.js` — baseline for future deploy compare. **DISCOVERY #7 NEW:** path filter `paths-ignore` MISSING `eval/**` → S31 RAG eval JSON commits triggered ~3m30s deploy wastefully (no code change). Consider em main weigh adding `'eval/**'` to filter if RAG telemetry commit frequency growing. **Pending S33 deploy triggers:** (i) Phase 10.1 G-H1 Hồ sơ NS first push — Mig 34 verify; (ii) Plan B-Wrap BW1-BW7 first commit — test gate baseline 111 → 118+ delta; (iii) Phase 9 UAT smoke V2 contract end-to-end. Token cost ~12K (Read MEMORY + 8 Bash curl/ssh/git/python parse).
- **2026-05-26 (S32 wrap — em main proxy curate + Phase 9 stabilize done + Phase 10 deploy ahead):** Session 32 đóng clean. Em chủ trì spawn em 1 lần S32 startup verify (a505a02d84fc1fabe alive, MEMORY 27→24.2KB post curate Run #231 PARTIAL detail archived q2). **NO Run triggered S32** — 5 commits S32 cumulative tất cả docs-only CI skip per #41 (b832f43 Phase 10 backlog + cce0963 stabilize batch + abcc1ed STATUS+HANDOFF wrap + 2 earlier S31). 0 prod deploy event. 3 endpoint smoke (api/admin/eoffice) still 200 OK post-S29 deploy. cert api.solutions.com.vn notAfter `2026-07-23` (verified via openssl s_client) — auto-renew ~2026-06-23, NOT urgent. **Plan G 11 module backlog DOCUMENTED migration-todos** + Plan B-Wrap test bundle BW1-BW7 spec ready. **Pending S33 deploy triggers em main spawn em:** (a) **Phase 10.1 G-H1 Hồ sơ NS Run verify** — first code commit Phase 10 Mig 34 + entity scaffold + CQRS + FE 2 app → poll Run + bundle hash rotate ×2 verify + Mig 34 applied prod sqlcmd verify + 5-stage checklist Stage 4 smoke endpoint (POST /api/employees create + GET /api/employees list). (b) **Plan B-Wrap test bundle post-push verify** — first commit BW1-BW7 codegen → 5-stage Stage 3 test gate baseline 111 → ~118+ test count delta report (UAT mode exception suspended cho Plan B-Wrap explicit test-after). (c) **Phase 9 UAT smoke V2 contract sample workflow create flow** — sqlcmd verify QT-HD-V2-001 ApplicableType=3 exists prod + Drafter create V2 contract end-to-end → CCM binh.le approve → terminal Phase=DaPhatHanh + gen mã HĐ + ContractLevelOpinions UPSERT. **Foundation entries CONFIRMED preserved:** 10-surface-point per-NV checklist + gotcha #48 SQLite tie-break + Discovery #6 INFRASTRUCTURE vs DEMO seed Stage 4.6. Token cost wrap ~3K. Tag: `[wrap, phase-9-to-phase-10, cicd]`.

View File

@ -324,6 +324,8 @@ KHÔNG `*` / `latest`. Critical pins:
## 📅 Recent activity (last 10 FIFO)
- **2026-05-26 (S33 Plan B G-H1 Task 5 — Phase 10.1 FE 2 app HRM scaffold Case 2 cookie-cutter cross-app mirror):** Em main spec deterministic 100% ULTRA-MINIMAL scope (Phase 1 read-only, no separate DetailTabs component, inline 6-section `<details>` collapsible, no satellite CRUD). Implementer Case 2 cross-app mirror 4-place ACCEPT clean. Tổng 14 file (3 NEW page × 2 app SHA256 IDENTICAL + 4 modified file: menuKeys+Layout+App × 2). **3 NEW file SHA256 verified IDENTICAL:** `types/employee.ts` CCFC70666568 + `pages/hrm/EmployeesListPage.tsx` DC859C897C5C + `pages/hrm/EmployeeCreatePage.tsx` C796F25D01AC. Pattern 16-bis 4-place mirror cross-app reinforced **4th time cumulative** (S29 Plan CA HF1 + S29 Plan B Chunk D + S33 Task 5). Pattern 12-bis cross-module FE port PE → Hrm reinforced **4th time** (Plan B Chunk C Mig 33 + Plan B G-H1 Task 4 BE + Task 5 FE). **Verify all clean:** fe-admin build PASS (21.4s 0 TS err 1429KB bundle warn expected), fe-user build PASS (9.2s 0 TS err 1345KB bundle warn expected), dotnet build PASS (1.59s 0 warn 0 err), dotnet test PASS **120/120 baseline preserve** (58 Domain + 62 Infra). **Ambiguities encountered:** 0 — spec deterministic 100%. **Files touched:** fe-admin/src/types/employee.ts (NEW, ~283 LOC), fe-admin/src/pages/hrm/EmployeesListPage.tsx (NEW, ~417 LOC), fe-admin/src/pages/hrm/EmployeeCreatePage.tsx (NEW, ~178 LOC), fe-admin/src/lib/menuKeys.ts (+3 LOC Hrm+HrmHoSo), fe-admin/src/components/Layout.tsx (+4 LOC staticMap Hrm_HoSo), fe-admin/src/App.tsx (+5 LOC imports+routes), 6 fe-user files mirror identical (3 SHA256 + 3 edit mirror Hrm_HoSo). **Out-of-scope respected:** KHÔNG satellite CRUD form, KHÔNG inline edit Header, KHÔNG PermissionGuard wire (em main Task 6 Hrm_HoSo policy registration). KHÔNG add Bg_*/Catalog* to fe-admin menuKeys.ts (em main defer optional). **Token cost estimate:** ~25k tokens (Read 8 reference + Write 3 file + Edit 6 + 3 Bash verify). KHÔNG commit (em main commits). Tag: `[mirror-page, phase-10.1, frontend]`.
- **2026-05-26 (S32 wrap — em main proxy curate + Plan G 11 module future scope):** Session 32 đóng clean. Em chủ trì spawn em 1 lần S32 startup verify (ab23cc322b5d495c0 alive, MEMORY size FLAG 36.2KB > 25KB). **Em main proxy curate Plan A3:** archived 5 verbose entries q2 (S25 Plan AB Chunk A + S25 wrap + S26 t1 Plan AG + S27 Plan CA Chunk B + S29 Plan B Chunk D detail) → 36.2→27.5KB. Patterns 1-19 + 12-bis + 16-bis foundation **preserved untouched**. **Plan G 11 module backlog DOCUMENTED migration-todos** với 10 Plan G-* atomic sprint. **Pending tasks em main S33 spawn em Case 2 cookie-cutter mirror:** (a) **Plan G-H1 Hồ sơ NS scaffold** — BE 6 entity (EmployeeProfile main + 5 satellite WorkHistory/Education/FamilyRelation/Skill/Document) + EF Config + DbInitializer seed 30 demo profile mirror 30 users + CQRS Create/Update/GetDetail/List + 6 endpoint controller + FE 2 app types/employee.ts + EmployeesPage 3-panel + EmployeeDetailTabs (6 section); (b) **Plan B-Wrap test bundle BW1-BW7 codegen** — Case 2 cookie-cutter mirror PE WorkflowService test pattern (PurchaseEvaluationWorkflowServiceReturnModeTests.cs structure) cho ContractWorkflowServiceApproveV2Tests.cs (7 test scenario spec deterministic migration-todos D-Bis); (c) **Plan G-O2 Phòng họp BookingCalendar** — Case 2 FE 2 app FullCalendar lib new dep; (d) **Plan G-O3..G-O6 Workflow Apps** — cookie-cutter mirror PE Mig 22-26 Plan B pattern 12-bis cross-module entity scaffold cho 4 module (Proposal/LeaveRequest/OtRequest/VehicleBooking/ItTicket). **REFUSE forward:** S33+ start Phase 10.1 G-H1 entity scaffold đúng spec deterministic, KHÔNG schema design (em main solo Mig 34 design). Token cost wrap ~5K. Tag: `[wrap, phase-9-to-phase-10, frontend+backend]`.
- **2026-05-26 (S32 startup — context verify + RAG live confirm + size FLAG > 25KB):** Em chủ trì spawn em verify Session 32 context. **Verify done:** (1) MEMORY size 36.2KB (Get-Item Length=36207 bytes) — **OVER 25KB threshold ~45% bigger** → FLAG cho em main schedule dedicated curate session per Pattern curate trigger rule line 364. KHÔNG self-curate vì em chủ trì preference reserve cho em main solo judgment call §6.5 KEEP vs CUT (S27 retrospective C1-C4 task lesson). (2) Patterns saved 1-12 foundation + 12-bis NEW S29 + 13-15 + 16-bis NEW S29 + 17-19 — total **17 numbered patterns** (Pattern 16 baseline implied trong recent activity S27 chưa numbered explicit). Pattern 12-bis (cross-module entity cookie-cutter mirror PE→Contract Mig 33) **SAVED line 178-200** confirmed present. Pattern 16-bis (4-place mirror cross-app S29 Plan CA Hotfix 1) **SAVED line 165-176** confirmed present. (3) MCP RAG tools **PRESENT**`mcp__rag-unified__search_memory` + `mcp__rag-unified__cross_project_search` both visible trong tools list. Test query "Pattern 12-bis cross-module entity cookie-cutter mirror PE Contract V2" top_k=3 returned 3 results với rerank scores **0.824/0.801/0.793** — all healthy > 0.7 threshold. S31 RAG v1.3 baseline PASS confirmed live post CLI restart. **Pending tasks em main có thể gọi em lại spawn S32+:** (a) Plan B-Wrap BW1-BW7 test bundle codegen Case 2 cookie-cutter mirror PE WorkflowService test pattern (regression ApproveV2Async + UPSERT LevelOpinions test) — 7 file new test class mirror PE test bundle structure; (b) ContractWorkflowMatrixView mirror PE WorkflowMatrixView Plan AA S24 (1 page mirror cross-module — Case 2 fits Pattern 13 read-only admin Designer mirror + Pattern 14 Tailwind JIT palette + Pattern 15 HTML table rowSpan iteration helper). **Decision tree forward:** Em chủ trì gọi em với task code edit → em ACCEPT case (a)/(b) khi spec deterministic, REFUSE nếu first-time pattern. Em chủ trì confirm Layer A governance still active scope SOLUTION_ERP. Token cost spawn này ~5k (3 Read + 1 RAG query + 1 Edit + final report). KHÔNG curate — defer em main full curate session. Tag: `[verify, phase-9, infra]`.

View File

@ -154,6 +154,8 @@ Flag commit nếu thấy `<PackageReference Include="MediatR" Version="14...` ho
## 📅 Recent activity (last 10 FIFO)
- **2026-05-26 (S33 Plan B G-H1 Phase 2 Task 4+5+6 pre-commit — PASS, Smart Friend 6× CLEAN):** Em main spawn em adversarial review 17 file uncommitted (3 BE new + 6 FE new + 6 FE mod + 2 Task 6 mod). **Independent verify SHA256 mirror 3 file PASS IDENTICAL**: `types/employee.ts` ccfc70666568, `EmployeesListPage.tsx` dc859c897c5c, `EmployeeCreatePage.tsx` c796f25d01ac — admin == user exact. **Cat 1 Wire BE**: 5 endpoint `GET /employees` (paged) + `GET /{id}` + `POST` (CreatedAtAction) + `PUT /{id}` (NoContent + ID match guard) + `DELETE /{id}` (soft NoContent) — tất cả real `mediator.Send`, 0 mock marker. Validator Create+Update đầy đủ (Phone MaxLen 20 + EmailAddress conditional When + decimal `.GreaterThanOrEqualTo(0)` lương/phép). Handler Create: load User (FindByIdAsync) → check existing EmployeeProfile UNIQUE (soft-deleted aware: throw ConflictException with distinct message) → atomic MaNhanVien codeGen SERIALIZABLE → entity save. Handler Get: Include 5 satellite + LEFT JOIN User/Department + projection. Handler List: filter Status + DepartmentId + Search (EmployeeCode/FullName Contains) + paging. **Cat 2 Schema**: Mig `AddEmployeeProfiles` timestamp 20260526110207 (= Mig 34 numeric by sort) — `EmployeeProfiles` + 5 satellite + `EmployeeCodeSequences` (7 table mới). UNIQUE indexes verified `IX_EmployeeProfiles_UserId` (line 309-313) + `IX_EmployeeProfiles_EmployeeCode` (line 293-297). FK Cascade Users. 6 Province/District/Ward cột plain Guid? defer FK G-H2 đúng comment. `EmployeeProfileConfiguration.cs` line 22-32 mirror Mig: UNIQUE + Cascade. MenuKeys.cs `All[]` line 108-114 đã có Hrm + HrmHoSo (line 112) — Admin auto-grant qua SeedAdminPermissionsAsync iterates All[]. DbInitializer.cs line 1484-1485 seed Hrm Order=28 + HrmHoSo Order=1 dưới Hrm parent. 28 không xung đột với Budgets=27. SeedDemoEmployeeProfilesAsync line 1945 NOT gated DemoSeed (placed OUTSIDE gate block) — đúng infrastructure pattern gotcha #51 lesson. **Cat 3 Security**: `[Authorize]` class-level present EmployeesController line 20 — no per-action policy yet (em main defer Phase 1.5). Input validation Create + Update Validator class đầy đủ. **Cat 4 Code quality**: TS6 erasableSyntaxOnly compliant — 10 enum dùng const-object pattern + `typeof X[keyof typeof X]`. Named exports (no default trừ App). Bundle size 1.43MB admin / 1.35MB user comparable baseline. ContactRound icon verified exist 5 places trong lucide-react bundle. DependencyInjection.cs line 39 registered `AddScoped<IEmployeeCodeGenerator, EmployeeCodeGenerator>`. **Cat 5 Test**: defer Phase 1.5 per em main spec UAT mode. Baseline 120/120 PASS preserved. **Smart Friend 6× cumulative CLEAN**: (1) S22 #44, (2) S25 #48, (3) S29 Plan CA password ≥12, (4) S29 Plan B ApplicableType, (5) S33 Plan C BW, (6) S33 Plan B Phase 2 NOW. KHÔNG lower bar — actual catches 0 MAJOR/CRITICAL. **3 MINOR observed (defer Phase 1.5)**: (a) Race condition EmployeeCode UNIQUE dưới SERIALIZABLE OK risk THẤP (per-year reset, mirror PE/HD pattern proven). (b) UpdateCommand 3 bool field IsCommunistParty/IsYouthUnion/IsTradeUnion không nullable → admin update partial sẽ accidentally reset (FE phải re-send all 3 every PUT — minor UX issue). (c) Delete handler `DateTime.UtcNow` direct không inject IDateTimeProvider — consistent existing PE/Contract Delete pattern, accept. **Special check verdicts**: gotcha #51 infrastructure seed gate compliance ✓ (em main cite gotcha #51 explicit comment line 1942-1944). gotcha #50 Layout staticMap mirror ✓ (Hrm_HoSo:/employees cả fe-admin line 57 + fe-user line 79). menuKeys.ts FE drift: pre-existing fe-admin minimal (16 key) vs fe-user (24 key) — em main Task 5 chỉ add Hrm 2 key cả 2 file, KHÔNG break Plan AA + Budget. Drift là pre-existing FE state intentional, không phải regression Plan B Phase 2. **Verdict**: PASS proceed commit. Token cost ~30K. Tag: `[adversarial-pass, hrm-mig34, smart-friend-6x-clean, phase-10]`.
- **2026-05-26 (S33 Plan C B-Wrap test bundle pre-commit — PASS, INDEPENDENT VERIFY 9/9 tests):** Em main spawn em adversarial Plan C B-Wrap Contract V2 test bundle review. 4 file mới: TestCurrentUser (31 LOC stub), ContractWorkflowServiceApproveV2Tests (BW1-4+7, 5 [Fact]), CreateContractCommandApplicableTypeTests (BW5, 1 [Fact]), ContractV2SchemaPersistenceTests (BW6 split 3 [Fact]). Total 9 [Fact]. **Independent verify ran `dotnet test --filter` → 9/9 PASS local trong 4.7s** (em main claim 120/120 baseline tăng từ 111 — verified). Spec mapping verify Cat 1: BW1 ContextNote `"Hoàn tất Cấp 1, sang Cấp 2 cùng Bước 1"` ✓ match service line 360, BW2 mã HĐ `"FLOCK01/HĐTP/SOL&BTBM/01"` ✓ match ContractCodeGenerator HĐTP format line 21, BW3 ContextNote `"Approver skip thẳng tới Bước 3 Cấp 2"` ✓ match service line 348 (lastStepIdx=2, lastLevelMaxOrder=2, prefix `[Approver skip thẳng tới Bước 3 Cấp 2 (NV cuối) — bỏ qua các Bước/Cấp trung gian]`), BW4 ForbiddenException `"Bước 1...Cấp 1: bạn không có"` ✓ match service line 263-264, BW5 ConflictException `"ApplicableType=Contract"` ✓ match handler line 84-85 (test correctly handles spec→actual exception type discrepancy: spec says ValidationException, actual ConflictException — em main inline comment line 84 docs the discrepancy), BW7 ConflictException `"skipToFinal chỉ hỗ trợ HĐ V2"` ✓ match service line 105-106. Schema verify Cat 2: Mig `AddContractLevelOpinions` (timestamp 20260522052240) actual position 33 by filename sort confirmed via Glob (33 mig + 33 designer + 1 snapshot in /Persistence/Migrations folder), spec ref "Mig 33" matches. UNIQUE composite `(ContractId, ApprovalWorkflowLevelId)` confirmed `ContractLevelOpinionConfiguration.cs:34`. FK Cascade Contract + FK Restrict Level confirmed line 27+32 + migration FK line 41+47. Test quality Cat 5: assertions specific (`Should().Be(2)` for levelOrder, `Should().Contain(...)` for substring match, `Should().ThrowAsync<X>()` with WithMessage wildcard), each [Fact] sets up fresh `IdentityFixture` + `using` disposal pattern (BW1-4+7 verbose using-block, BW6 `using var fix` shorthand — minor style inconsistency but functional same). Smart Friend independence note: **5 lần cumulative Smart Friend catches — em main + Implementer làm TỐT lần này, KHÔNG có catch MAJOR**. (1) S22 #44, (2) S25 #48, (3) S29 ApplicableType, (4) S29 DemoSeed, (5) S33 BW = clean. Implementer + em main spec mapping accurate, exact string match between assertions and service strings. **Minor (3, defer):** (a) `CreateService` helper method `ContractWorkflowServiceApproveV2Tests.cs:27-44` unused dead code — 5 [Fact] manually recreate inline (cleaner but DRY violation). Cleanup recommend Plan C-Hotfix or next test bundle. (b) `TestCurrentUser.Roles` constructor params `string[] roles` allows null → defensive `roles ?? Array.Empty<string>()` line 27 OK but C# warning shadow. (c) BW6 split 3 [Fact] cleanly separated DUPLICATE + UPSERT + Cascade per spec — 9 tests OK not over-engineer (each invariant tested isolated). **Defer noted**: ApproveV2Async still ~150 LOC, BW1-4+7 cover happy + terminal + skip + outsider + V1 fallback — Plan B-Wrap roadmap mentions BW Bonus future test: OR-of-N multi-NV (3 NV cùng Cấp 1, only 1 needs approve), idempotent UPSERT (Cấp 1 approve → reject → approve lại same row Comment update), Mig 32 seed idempotent guard. Not blocker for current bundle commit. Token cost spawn ~22K. Verdict: **PASS proceed commit** (9 [Fact] all pass independent verify, spec match service exact, schema 3 invariant tested, 0 critical/major, 3 minor cosmetic). Recommendation: commit 4 file as proposed, baseline tăng 111→120. Tag: `[adversarial-pass, test-bundle, contract-v2, smart-friend-5x-clean]`.
- **2026-05-26 (S33 startup — drift audit readonly, NONE actual review):** Em main spawn em S33 drift assessment 3 area (gotchas / CLAUDE.md root+docs / 4 sub-agent .md). **Verdict overall: MODERATE drift accumulated S19→S32 chưa patch** — late but not severe. Findings: (a) `docs/gotchas.md` actual count = **52 entries** (grep `^### \d+\.` confirm), claim S32 = 52 → MATCH NONE drift. #50/#51/#52 detail entries present line 883/839/924 (out-of-order numbering but content full). (b) CLAUDE.md (root) **SEVERE stale**: line 87 "Hiện có 26 migration → 59 bảng" (actual 33 mig + 60 table per S32 wrap), line 66+87 "81 test pass" (actual 111), line 133 "26 bẫy đã gặp" (actual 52). docs/CLAUDE.md line 65 "38 pitfall" stale similar. (c) 4 sub-agent .md drift mixed: cicd-monitor.md line 232-233 already patched S29 "111/111 PASS (58 Domain + 53 Infra)" + "Migrations: 33" CORRECT no drift; investigator.md line 77 "44 gotchas hiện tại" STALE +8; reviewer.md line 92 "44 active gotchas" STALE +8; implementer.md line 123+141 "baseline 81 preserve" STALE +30. **Critical drift requiring immediate patch**: CLAUDE.md root 3 stale fact (mig 26→33, test 81→111, gotcha 26→52) — agent context first-load file, drift mislead future spawns. **Optional defer 2026-06-01 audit**: 3 sub-agent .md gotcha+test count stale (cosmetic, no functional impact). Migrations folder discovery: actual path `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/` not `src/Backend/SolutionErp.Infrastructure/Migrations/``cicd-monitor.md` line 149 path hint correct, but agents/runbooks may have stale absolute path. Token cost ~7K. Tag: `[startup-audit, drift-moderate, claude-md-severe]`. Recommendation: bro decide patch CLAUDE.md ngay (3 line edit) hoặc defer cycle audit ngày 2026-06-01.

View File

@ -26,6 +26,8 @@ import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreat
import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage'
import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage'
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
function App() {
return (
@ -68,6 +70,9 @@ function App() {
<Route path="/budgets" element={<BudgetsListPage />} />
<Route path="/budgets/new" element={<BudgetCreatePage />} />
<Route path="/budgets/:id" element={<BudgetDetailPage />} />
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
<Route path="/employees" element={<EmployeesListPage />} />
<Route path="/employees/new" element={<EmployeeCreatePage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route

View File

@ -51,6 +51,10 @@ function resolvePath(key: string): string | null {
Bg_List: '/budgets',
Bg_Create: '/budgets/new',
Bg_Pending: '/budgets?phase=Pending',
// [Phase 10.1 G-H1 S33 2026-05-26] Module Hồ sơ Nhân sự (Mig 34). LESSON
// Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới
// — nếu thiếu, MenuLeaf line ~198 `if (!path) return null` → sidebar drop silent.
Hrm_HoSo: '/employees',
}
if (staticMap[key]) return staticMap[key]

View File

@ -19,6 +19,9 @@ export const MenuKeys = {
ApprovalWorkflowsV2: 'ApprovalWorkflowsV2',
AwV2_DuyetNcc: 'AwV2_DuyetNcc',
AwV2_DuyetNccPhuongAn: 'AwV2_DuyetNccPhuongAn',
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)
Hrm: 'Hrm',
HrmHoSo: 'Hrm_HoSo',
} as const
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]

View File

@ -0,0 +1,205 @@
// Page Create Hồ sơ Nhân sự — Header form minimal.
// Phase 10.1 G-H1 Phase 1 (S33 Task 5): chỉ Header field tối thiểu để link
// User → EmployeeProfile. Section còn lại edit sau qua DetailPanel (Phase 1.5).
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { UserPlus } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { Label } from '@/components/ui/Label'
import { Button } from '@/components/ui/Button'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import type { Paged } from '@/types/master'
import { EmployeeStatus, EmployeeStatusLabel, Gender, GenderLabel } from '@/types/employee'
// User dropdown list response (mirror BE UsersController list shape — id/fullName/email subset).
type UserOption = {
id: string
fullName: string | null
email: string
}
type CreateForm = {
userId: string
employeeStatus: string
hireDate: string
dateOfBirth: string
gender: string
phone: string
nationality: string
}
const todayIso = () => new Date().toISOString().slice(0, 10)
const initial: CreateForm = {
userId: '',
employeeStatus: String(EmployeeStatus.Active),
hireDate: todayIso(),
dateOfBirth: '',
gender: '',
phone: '',
nationality: 'Việt Nam',
}
const PHONE_RE = /^0\d{9,10}$/
const isValidPhone = (s: string) => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, ''))
export function EmployeeCreatePage() {
const navigate = useNavigate()
const [form, setForm] = useState<CreateForm>(initial)
// Lấy list user để pick — pageSize lớn cho admin pick dễ.
const users = useQuery({
queryKey: ['users-for-employee-create'],
queryFn: async () =>
(await api.get<Paged<UserOption>>('/users', { params: { page: 1, pageSize: 200 } })).data.items,
})
const create = useMutation({
mutationFn: async () => {
const body = {
userId: form.userId,
employeeStatus: Number(form.employeeStatus),
hireDate: form.hireDate || null,
dateOfBirth: form.dateOfBirth || null,
gender: form.gender ? Number(form.gender) : null,
phone: form.phone.trim() || null,
nationality: form.nationality.trim() || null,
}
return (await api.post<{ id: string }>('/employees', body)).data
},
onSuccess: data => {
toast.success('Đã tạo hồ sơ NV.')
navigate(`/employees?id=${data.id}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
function submit(e: FormEvent) {
e.preventDefault()
if (!form.userId) {
toast.error('Vui lòng chọn user.')
return
}
if (!isValidPhone(form.phone)) {
toast.error('SĐT không hợp lệ (10-11 số, bắt đầu bằng 0).')
return
}
create.mutate()
}
return (
<div className="mx-auto max-w-2xl space-y-4 p-6">
<header className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-brand-600" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">Tạo Hồ Nhân sự mới</h1>
</header>
<form onSubmit={submit} className="space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div>
<Label htmlFor="userId">User <span className="text-red-500">*</span></Label>
<Select
id="userId"
value={form.userId}
onChange={e => setForm(f => ({ ...f, userId: e.target.value }))}
required
>
<option value=""> Chọn user </option>
{(users.data ?? []).map(u => (
<option key={u.id} value={u.id}>
{u.fullName ? `${u.fullName} (${u.email})` : u.email}
</option>
))}
</Select>
<p className="mt-1 text-xs text-slate-500">
Mỗi user chỉ đưc link với 1 hồ NV. Tạo user mới mục System &gt; Users nếu thiếu.
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label htmlFor="employeeStatus">Trạng thái</Label>
<Select
id="employeeStatus"
value={form.employeeStatus}
onChange={e => setForm(f => ({ ...f, employeeStatus: e.target.value }))}
>
<option value={String(EmployeeStatus.Active)}>{EmployeeStatusLabel[EmployeeStatus.Active]}</option>
<option value={String(EmployeeStatus.OnLeave)}>{EmployeeStatusLabel[EmployeeStatus.OnLeave]}</option>
<option value={String(EmployeeStatus.Resigned)}>{EmployeeStatusLabel[EmployeeStatus.Resigned]}</option>
</Select>
</div>
<div>
<Label htmlFor="hireDate">Ngày vào làm</Label>
<Input
id="hireDate"
type="date"
value={form.hireDate}
onChange={e => setForm(f => ({ ...f, hireDate: e.target.value }))}
/>
</div>
<div>
<Label htmlFor="gender">Giới tính</Label>
<Select
id="gender"
value={form.gender}
onChange={e => setForm(f => ({ ...f, gender: e.target.value }))}
>
<option value=""></option>
<option value={String(Gender.Male)}>{GenderLabel[Gender.Male]}</option>
<option value={String(Gender.Female)}>{GenderLabel[Gender.Female]}</option>
<option value={String(Gender.Other)}>{GenderLabel[Gender.Other]}</option>
</Select>
</div>
<div>
<Label htmlFor="dateOfBirth">Ngày sinh</Label>
<Input
id="dateOfBirth"
type="date"
value={form.dateOfBirth}
onChange={e => setForm(f => ({ ...f, dateOfBirth: e.target.value }))}
/>
</div>
<div>
<Label htmlFor="phone">SĐT</Label>
<Input
id="phone"
value={form.phone}
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
placeholder="0912345678"
/>
</div>
<div>
<Label htmlFor="nationality">Quốc tịch</Label>
<Input
id="nationality"
value={form.nationality}
onChange={e => setForm(f => ({ ...f, nationality: e.target.value }))}
/>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-slate-100 pt-4">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={create.isPending}>
Huỷ
</Button>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? 'Đang lưu...' : 'Tạo hồ sơ'}
</Button>
</div>
<p className="text-xs text-slate-400">
Các thông tin chi tiết (giấy tờ, đa chỉ, lương, kỹ năng, ...) thể bổ sung mục Sửa hồ sau khi tạo.
</p>
</form>
</div>
)
}

View File

@ -0,0 +1,573 @@
// List + Detail Hồ sơ Nhân sự (HRM) — 2-panel: filter sidebar | list table + inline detail.
// Phase 10.1 G-H1 Phase 1 ULTRA-MINIMAL scope (S33 Task 5):
// - Read-only mọi section (Edit Header defer Phase 1.5)
// - 6 section render inline trong right panel qua `<details>` HTML native
// - NO separate DetailTabs component, NO satellite CRUD form
// Pattern 16-bis 4-place mirror cross-app (4th reinforcement S33).
// URL params: id (selected), q (search), status, deptId
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { UserCircle2, Search, Plus, X } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/EmptyState'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { Paged, Department } from '@/types/master'
import {
EmployeeStatusColor,
EmployeeStatusLabel,
GenderLabel,
MaritalStatusLabel,
EmployeeTypeLabel,
DegreeLevelLabel,
EducationModeLabel,
GradeLevelLabel,
FamilyRelationKindLabel,
SkillKind,
SkillKindLabel,
EmployeeDocumentTypeLabel,
type EmployeeListItem,
type EmployeeDetail,
} from '@/types/employee'
const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleDateString('vi-VN') : '—')
const fmtDateTime = (s: string | null) => (s ? new Date(s).toLocaleString('vi-VN') : '—')
export function EmployeesListPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp, setSp] = useSearchParams()
const search = sp.get('q') ?? ''
const statusFilter = sp.get('status') ?? ''
const deptFilter = sp.get('deptId') ?? ''
const selectedId = sp.get('id')
const [localSearch, setLocalSearch] = useState(search)
const departments = useQuery({
queryKey: ['departments-all-hrm'],
queryFn: async () =>
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
})
const list = useQuery({
queryKey: ['employees-list', { search, statusFilter, deptFilter }],
queryFn: async () => {
const res = await api.get<Paged<EmployeeListItem>>('/employees', {
params: {
pageSize: 100,
search: search || undefined,
status: statusFilter || undefined,
departmentId: deptFilter || undefined,
},
})
return res.data
},
})
const detail = useQuery({
queryKey: ['employee-detail', selectedId],
queryFn: async () => (await api.get<EmployeeDetail>(`/employees/${selectedId}`)).data,
enabled: !!selectedId,
})
const del = useMutation({
mutationFn: async (id: string) => api.delete(`/employees/${id}`),
onSuccess: () => {
toast.success('Đã xoá hồ sơ NV.')
setParam('id', null)
qc.invalidateQueries({ queryKey: ['employees-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
function setParam(key: string, value: string | null) {
const next = new URLSearchParams(sp)
if (value == null || value === '') next.delete(key)
else next.set(key, value)
setSp(next, { replace: true })
}
function applySearch(e: React.FormEvent) {
e.preventDefault()
setParam('q', localSearch.trim() || null)
}
function resetFilters() {
setLocalSearch('')
setSp(new URLSearchParams(), { replace: true })
}
return (
<div className="grid h-full grid-cols-[340px_1fr] gap-4 p-4">
{/* ========== LEFT PANEL: filter ========== */}
<aside className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<header className="flex items-center gap-2">
<UserCircle2 className="h-5 w-5 text-brand-600" />
<h2 className="text-base font-semibold tracking-tight text-slate-900">Hồ Nhân sự</h2>
</header>
<form onSubmit={applySearch} className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">Tìm kiếm</label>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={localSearch}
onChange={e => setLocalSearch(e.target.value)}
placeholder="Mã NV hoặc họ tên..."
className="pl-8"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">Trạng thái</label>
<Select
value={statusFilter}
onChange={e => setParam('status', e.target.value || null)}
>
<option value="">Tất cả</option>
<option value="1">Đang làm việc</option>
<option value="2">Nghỉ phép</option>
<option value="3">Đã nghỉ việc</option>
</Select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">Phòng ban</label>
<Select
value={deptFilter}
onChange={e => setParam('deptId', e.target.value || null)}
>
<option value="">Tất cả phòng ban</option>
{(departments.data ?? []).map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
</div>
<div className="flex gap-2 pt-1">
<Button type="submit" size="sm" className="flex-1">Tìm</Button>
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
<X className="h-3.5 w-3.5" />
Reset
</Button>
</div>
</form>
<div className="mt-2 border-t border-slate-100 pt-3 text-xs text-slate-500">
Tổng: <span className="font-semibold text-slate-700">{list.data?.total ?? 0}</span> hồ
</div>
</aside>
{/* ========== RIGHT PANEL: list table + selected detail ========== */}
<section className="flex flex-col gap-3 overflow-hidden">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-slate-700">Danh sách nhân viên</h3>
<Button size="sm" onClick={() => navigate('/employees/new')}>
<Plus className="h-4 w-4" />
Tạo mới
</Button>
</div>
<div className="overflow-auto rounded-lg border border-slate-200 bg-white shadow-sm">
{list.isLoading ? (
<div className="p-10 text-center text-sm text-slate-500">Đang tải...</div>
) : !list.data || list.data.items.length === 0 ? (
<EmptyState
icon={UserCircle2}
title="Chưa có hồ sơ NV nào"
description="Bấm 'Tạo mới' để thêm hồ sơ nhân viên đầu tiên."
/>
) : (
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2 text-left font-medium"> NV</th>
<th className="px-3 py-2 text-left font-medium">Họ tên</th>
<th className="px-3 py-2 text-left font-medium">Phòng ban</th>
<th className="px-3 py-2 text-left font-medium">Trạng thái</th>
<th className="px-3 py-2 text-left font-medium">SĐT</th>
</tr>
</thead>
<tbody>
{list.data.items.map(e => (
<tr
key={e.id}
onClick={() => setParam('id', e.id)}
className={cn(
'cursor-pointer border-b border-slate-100 transition hover:bg-slate-50',
selectedId === e.id && 'bg-brand-50 hover:bg-brand-50',
)}
>
<td className="px-3 py-2 font-mono text-xs text-slate-700">{e.employeeCode}</td>
<td className="px-3 py-2 font-medium text-slate-900">{e.fullName ?? '—'}</td>
<td className="px-3 py-2 text-slate-600">{e.departmentName ?? '—'}</td>
<td className="px-3 py-2">
<span className={cn('inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium', EmployeeStatusColor[e.status])}>
{EmployeeStatusLabel[e.status]}
</span>
</td>
<td className="px-3 py-2 text-slate-600">{e.phone ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* ========== Selected detail (6 collapsible section inline) ========== */}
{selectedId && (
<div className="overflow-auto rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
{detail.isLoading ? (
<div className="text-sm text-slate-500">Đang tải chi tiết...</div>
) : !detail.data ? (
<div className="text-sm text-red-600">Không tìm thấy hồ .</div>
) : (
<EmployeeDetailSections detail={detail.data} onDelete={() => del.mutate(detail.data!.id)} />
)}
</div>
)}
</section>
</div>
)
}
// ========== Inline 6-section read-only detail ==========
function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; onDelete: () => void }) {
return (
<div className="space-y-3">
{/* Header bar */}
<header className="flex items-start justify-between gap-3 border-b border-slate-200 pb-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 text-slate-400">
{detail.photoUrl ? (
<img src={detail.photoUrl} alt={detail.fullName ?? ''} className="h-12 w-12 rounded-full object-cover" />
) : (
<UserCircle2 className="h-8 w-8" />
)}
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">{detail.fullName ?? '—'}</h3>
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-500">
<span className="font-mono">{detail.employeeCode}</span>
<span></span>
<span className={cn('inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium', EmployeeStatusColor[detail.employeeStatus])}>
{EmployeeStatusLabel[detail.employeeStatus]}
</span>
{detail.departmentName && (
<>
<span></span>
<span>{detail.departmentName}</span>
</>
)}
</div>
</div>
</div>
<Button variant="danger" size="sm" onClick={() => {
if (confirm(`Xoá hồ sơ "${detail.fullName ?? detail.employeeCode}"?`)) onDelete()
}}>
Xoá hồ
</Button>
</header>
{/* Section 1: Cơ bản */}
<Section title="1. Thông tin cơ bản" defaultOpen>
<Grid2>
<Field label="Họ tên" value={detail.fullName} />
<Field label="Mã NV" value={detail.employeeCode} mono />
<Field label="Email" value={detail.email} />
<Field label="Email cá nhân" value={detail.personalEmail} />
<Field label="SĐT" value={detail.phone} />
<Field label="SĐT nội bộ" value={detail.internalPhone} />
<Field label="Ngày sinh" value={fmtDate(detail.dateOfBirth)} />
<Field label="Giới tính" value={detail.gender != null ? GenderLabel[detail.gender] : null} />
<Field label="Tình trạng hôn nhân" value={detail.maritalStatus != null ? MaritalStatusLabel[detail.maritalStatus] : null} />
<Field label="Loại NV" value={detail.employeeType != null ? EmployeeTypeLabel[detail.employeeType] : null} />
<Field label="Quốc tịch" value={detail.nationality} />
<Field label="Dân tộc" value={detail.ethnicity} />
<Field label="Tôn giáo" value={detail.religion} />
<Field label="Nơi sinh" value={detail.birthPlace} />
<Field label="Quê quán" value={detail.hometown} />
<Field label="Phòng ban" value={detail.departmentName} />
<Field label="Vị trí công tác" value={detail.workLocation} />
<Field label="Mã chấm công" value={detail.timekeepingCode} />
<Field label="Ngày vào làm" value={fmtDate(detail.hireDate)} />
<Field label="Ngày nghỉ việc" value={fmtDate(detail.resignDate)} />
</Grid2>
<SubBlock title="Giấy tờ">
<Grid2>
<Field label="CMND/CCCD" value={detail.idCardNumber} mono />
<Field label="Ngày cấp" value={fmtDate(detail.idCardIssueDate)} />
<Field label="Nơi cấp" value={detail.idCardIssuePlace} />
<Field label="Hộ chiếu" value={detail.passportNumber} mono />
<Field label="MST cá nhân" value={detail.taxCode} mono />
<Field label="Số BHXH" value={detail.socialInsuranceNumber} mono />
</Grid2>
</SubBlock>
<SubBlock title="Địa chỉ">
<Grid2>
<Field label="Thường trú" value={detail.permanentAddressText} />
<Field label="Số nhà / Đường (Thường trú)" value={detail.streetAddressPermanent} />
<Field label="Tạm trú" value={detail.temporaryAddressText} />
<Field label="Số nhà / Đường (Tạm trú)" value={detail.streetAddressTemporary} />
</Grid2>
</SubBlock>
<SubBlock title="Liên hệ khẩn cấp">
<Grid2>
<Field label="Họ tên" value={detail.emergencyContactName} />
<Field label="SĐT" value={detail.emergencyContactPhone} />
<Field label="Địa chỉ" value={detail.emergencyContactAddress} />
</Grid2>
</SubBlock>
<SubBlock title="Lương + Phép + BHXH">
<Grid2>
<Field label="Lương cơ bản" value={detail.baseSalary != null ? detail.baseSalary.toLocaleString('vi-VN') + ' đ' : null} />
<Field label="Tổng lương" value={detail.totalSalary != null ? detail.totalSalary.toLocaleString('vi-VN') + ' đ' : null} />
<Field label="Phép năm" value={detail.annualLeaveDays != null ? `${detail.annualLeaveDays} ngày` : null} />
<Field label="Phép còn lại" value={detail.remainingLeaveDays != null ? `${detail.remainingLeaveDays} ngày` : null} />
<Field label="Phép bù" value={detail.compensatoryLeaveDays != null ? `${detail.compensatoryLeaveDays} ngày` : null} />
<Field label="Phép thâm niên" value={detail.seniorityLeaveDays != null ? `${detail.seniorityLeaveDays} ngày` : null} />
<Field label="BHXH bắt đầu" value={fmtDate(detail.socialInsuranceStartDate)} />
<Field label="Nơi đăng ký KCB" value={detail.medicalRegistrationPlace} />
</Grid2>
</SubBlock>
<SubBlock title="Trình độ + Sức khoẻ + Ngân hàng">
<Grid2>
<Field label="Trình độ chuyên môn" value={detail.qualification} />
<Field label="Học hàm" value={detail.academicTitle} />
<Field label="Chiều cao" value={detail.heightCm != null ? `${detail.heightCm} cm` : null} />
<Field label="Cân nặng" value={detail.weightKg != null ? `${detail.weightKg} kg` : null} />
<Field label="Nhóm máu" value={detail.bloodType} />
<Field label="Tài khoản NH" value={detail.bankAccount} mono />
<Field label="Ngân hàng" value={detail.bankName} />
<Field label="Chi nhánh" value={detail.bankBranch} />
</Grid2>
</SubBlock>
<SubBlock title="Đoàn thể">
<Grid2>
<Field label="Đảng viên" value={detail.isCommunistParty ? 'Có' : 'Không'} />
<Field label="Ngày kết nạp Đảng" value={fmtDate(detail.communistPartyJoinDate)} />
<Field label="Đoàn viên" value={detail.isYouthUnion ? 'Có' : 'Không'} />
<Field label="Ngày kết nạp Đoàn" value={fmtDate(detail.youthUnionJoinDate)} />
<Field label="Công đoàn" value={detail.isTradeUnion ? 'Có' : 'Không'} />
<Field label="Ngày kết nạp CĐ" value={fmtDate(detail.tradeUnionJoinDate)} />
</Grid2>
</SubBlock>
{detail.notes && (
<SubBlock title="Ghi chú">
<p className="whitespace-pre-wrap text-sm text-slate-700">{detail.notes}</p>
</SubBlock>
)}
</Section>
{/* Section 2: Công tác */}
<Section title={`2. Quá trình công tác (${detail.workHistories.length})`}>
{detail.workHistories.length === 0 ? (
<EmptyHint text="Chưa có quá trình công tác nào." />
) : (
<div className="space-y-2">
{detail.workHistories.map(w => (
<div key={w.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm">
<div className="flex items-baseline justify-between">
<div className="font-medium text-slate-900">{w.companyName}</div>
<div className="text-xs text-slate-500">
{fmtDate(w.fromDate)} {fmtDate(w.toDate)}
</div>
</div>
{w.jobTitle && <div className="mt-1 text-xs text-slate-600">Chức vụ: {w.jobTitle}</div>}
{w.industry && <div className="text-xs text-slate-600">Ngành: {w.industry}</div>}
{w.companyAddress && <div className="text-xs text-slate-500">Đa chỉ: {w.companyAddress}</div>}
{w.jobDescription && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{w.jobDescription}</div>}
{w.resignReason && <div className="mt-1 text-xs italic text-slate-500"> do nghỉ: {w.resignReason}</div>}
</div>
))}
</div>
)}
</Section>
{/* Section 3: Đào tạo */}
<Section title={`3. Quá trình đào tạo (${detail.educations.length})`}>
{detail.educations.length === 0 ? (
<EmptyHint text="Chưa có quá trình đào tạo nào." />
) : (
<div className="space-y-2">
{detail.educations.map(ed => (
<div key={ed.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm">
<div className="flex items-baseline justify-between">
<div className="font-medium text-slate-900">{ed.schoolName}</div>
<div className="text-xs text-slate-500">
{fmtDate(ed.fromDate)} {fmtDate(ed.toDate)}
</div>
</div>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-600">
{ed.major && <span>Chuyên ngành: {ed.major}</span>}
{ed.degreeLevel != null && <span>Bằng cấp: {DegreeLevelLabel[ed.degreeLevel]}</span>}
{ed.educationMode != null && <span>Hình thức: {EducationModeLabel[ed.educationMode]}</span>}
{ed.gradeLevel != null && <span>Xếp loại: {GradeLevelLabel[ed.gradeLevel]}</span>}
</div>
{ed.certificateIssueDate && <div className="mt-1 text-xs text-slate-500">Ngày cấp bằng: {fmtDate(ed.certificateIssueDate)}</div>}
{ed.notes && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{ed.notes}</div>}
</div>
))}
</div>
)}
</Section>
{/* Section 4: Thân nhân */}
<Section title={`4. Quan hệ gia đình (${detail.familyRelations.length})`}>
{detail.familyRelations.length === 0 ? (
<EmptyHint text="Chưa có thông tin thân nhân." />
) : (
<table className="w-full text-sm">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500">
<tr>
<th className="py-2 pr-3 text-left font-medium">Họ tên</th>
<th className="py-2 pr-3 text-left font-medium">Quan hệ</th>
<th className="py-2 pr-3 text-left font-medium">Năm sinh</th>
<th className="py-2 pr-3 text-left font-medium">Nghề nghiệp</th>
<th className="py-2 pr-3 text-left font-medium">SĐT</th>
</tr>
</thead>
<tbody>
{detail.familyRelations.map(f => (
<tr key={f.id} className="border-b border-slate-100">
<td className="py-2 pr-3 font-medium text-slate-800">{f.fullName}</td>
<td className="py-2 pr-3 text-slate-600">{FamilyRelationKindLabel[f.relationship]}</td>
<td className="py-2 pr-3 text-slate-600">{f.birthYear ?? '—'}</td>
<td className="py-2 pr-3 text-slate-600">{f.occupation ?? '—'}</td>
<td className="py-2 pr-3 text-slate-600">{f.phone ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</Section>
{/* Section 5: Kỹ năng */}
<Section title={`5. Kỹ năng (${detail.skills.length})`}>
{detail.skills.length === 0 ? (
<EmptyHint text="Chưa có thông tin kỹ năng." />
) : (
<div className="space-y-3">
{[SkillKind.Computer, SkillKind.Language, SkillKind.Other].map(kind => {
const group = detail.skills.filter(s => s.kind === kind)
if (group.length === 0) return null
return (
<div key={kind}>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
{SkillKindLabel[kind]}
</div>
<ul className="space-y-1">
{group.map(s => (
<li key={s.id} className="rounded-md border border-slate-200 bg-slate-50/50 px-3 py-2 text-sm">
<div className="flex items-baseline justify-between">
<span className="font-medium text-slate-800">{s.name}</span>
{s.level && <span className="text-xs text-slate-500">{s.level}</span>}
</div>
{s.languageId && <div className="text-xs text-slate-500">: {s.languageId}</div>}
</li>
))}
</ul>
</div>
)
})}
</div>
)}
</Section>
{/* Section 6: Hồ sơ */}
<Section title={`6. Hồ sơ giấy tờ (${detail.documents.length})`}>
{detail.documents.length === 0 ? (
<EmptyHint text="Chưa có hồ sơ giấy tờ nào." />
) : (
<table className="w-full text-sm">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500">
<tr>
<th className="py-2 pr-3 text-left font-medium">Loại</th>
<th className="py-2 pr-3 text-left font-medium">Tên file</th>
<th className="py-2 pr-3 text-left font-medium">Ngày cấp</th>
<th className="py-2 pr-3 text-left font-medium">Ngày hết hạn</th>
</tr>
</thead>
<tbody>
{detail.documents.map(doc => (
<tr key={doc.id} className="border-b border-slate-100">
<td className="py-2 pr-3 text-slate-600">{EmployeeDocumentTypeLabel[doc.documentType]}</td>
<td className="py-2 pr-3">
<a href={doc.filePath} target="_blank" rel="noreferrer" className="text-brand-700 hover:underline">
{doc.fileName}
</a>
</td>
<td className="py-2 pr-3 text-slate-600">{fmtDate(doc.issueDate)}</td>
<td className="py-2 pr-3 text-slate-600">{fmtDate(doc.expiryDate)}</td>
</tr>
))}
</tbody>
</table>
)}
</Section>
<footer className="mt-2 border-t border-slate-100 pt-2 text-xs text-slate-400">
Tạo: {fmtDateTime(detail.createdAt)}
{detail.updatedAt && <> · Cập nhật: {fmtDateTime(detail.updatedAt)}</>}
</footer>
</div>
)
}
// ========== Helpers ==========
function Section({ title, children, defaultOpen }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
return (
<details open={defaultOpen} className="group rounded-md border border-slate-200 bg-white">
<summary className="flex cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 text-sm font-medium text-slate-800 hover:bg-slate-50">
<span>{title}</span>
<span className="text-xs text-slate-400 group-open:rotate-90 transition-transform"></span>
</summary>
<div className="space-y-3 border-t border-slate-100 p-3">{children}</div>
</details>
)
}
function SubBlock({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</div>
{children}
</div>
)
}
function Grid2({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-1 gap-x-4 gap-y-2 md:grid-cols-2">{children}</div>
}
function Field({ label, value, mono }: { label: string; value: string | number | null; mono?: boolean }) {
return (
<div className="text-sm">
<div className="text-xs text-slate-500">{label}</div>
<div className={cn('text-slate-800', mono && 'font-mono text-xs')}>
{value == null || value === '' ? '—' : value}
</div>
</div>
)
}
function EmptyHint({ text }: { text: string }) {
return <div className="py-4 text-center text-sm text-slate-400">{text}</div>
}

View File

@ -0,0 +1,306 @@
// Types cho module Hồ sơ Nhân sự (HRM) — mirror BE Domain.Hrm.Enums + DTOs.
// Phase 10.1 G-H1 (S33) — Pattern 12-bis cross-module FE port PE → Hrm (4th
// reinforcement). TS6 erasableSyntaxOnly cấm enum → const-object pattern bắt buộc.
// ========== Enum mirror BE Domain.Hrm.Enums (10 enum) ==========
export const EmployeeStatus = {
Active: 1,
OnLeave: 2,
Resigned: 3,
} as const
export type EmployeeStatus = typeof EmployeeStatus[keyof typeof EmployeeStatus]
export const EmployeeStatusLabel: Record<number, string> = {
1: 'Đang làm việc',
2: 'Nghỉ phép',
3: 'Đã nghỉ việc',
}
export const EmployeeStatusColor: Record<number, string> = {
1: 'bg-emerald-100 text-emerald-700',
2: 'bg-amber-100 text-amber-700',
3: 'bg-slate-100 text-slate-600',
}
export const Gender = {
Male: 1,
Female: 2,
Other: 3,
} as const
export type Gender = typeof Gender[keyof typeof Gender]
export const GenderLabel: Record<number, string> = {
1: 'Nam',
2: 'Nữ',
3: 'Khác',
}
export const MaritalStatus = {
Single: 1,
Married: 2,
Divorced: 3,
Widowed: 4,
} as const
export type MaritalStatus = typeof MaritalStatus[keyof typeof MaritalStatus]
export const MaritalStatusLabel: Record<number, string> = {
1: 'Độc thân',
2: 'Đã kết hôn',
3: 'Đã ly hôn',
4: 'Goá',
}
export const EmployeeType = {
FullTime: 1,
PartTime: 2,
Intern: 3,
Contractor: 4,
} as const
export type EmployeeType = typeof EmployeeType[keyof typeof EmployeeType]
export const EmployeeTypeLabel: Record<number, string> = {
1: 'Chính thức',
2: 'Bán thời gian',
3: 'Thực tập',
4: 'Khoán việc',
}
export const DegreeLevel = {
College: 1,
Bachelor: 2,
Master: 3,
PhD: 4,
} as const
export type DegreeLevel = typeof DegreeLevel[keyof typeof DegreeLevel]
export const DegreeLevelLabel: Record<number, string> = {
1: 'Cao đẳng',
2: 'Đại học',
3: 'Thạc sĩ',
4: 'Tiến sĩ',
}
export const EducationMode = {
FullTime: 1,
PartTime: 2,
Distance: 3,
} as const
export type EducationMode = typeof EducationMode[keyof typeof EducationMode]
export const EducationModeLabel: Record<number, string> = {
1: 'Chính quy',
2: 'Tại chức',
3: 'Từ xa',
}
export const GradeLevel = {
Average: 1,
Good: 2,
Excellent: 3,
} as const
export type GradeLevel = typeof GradeLevel[keyof typeof GradeLevel]
export const GradeLevelLabel: Record<number, string> = {
1: 'Trung bình',
2: 'Khá',
3: 'Giỏi',
}
export const FamilyRelationKind = {
Father: 1,
Mother: 2,
Spouse: 3,
Child: 4,
Sibling: 5,
Other: 99,
} as const
export type FamilyRelationKind = typeof FamilyRelationKind[keyof typeof FamilyRelationKind]
export const FamilyRelationKindLabel: Record<number, string> = {
1: 'Cha',
2: 'Mẹ',
3: 'Vợ/Chồng',
4: 'Con',
5: 'Anh/Chị/Em ruột',
99: 'Khác',
}
export const SkillKind = {
Computer: 1,
Language: 2,
Other: 3,
} as const
export type SkillKind = typeof SkillKind[keyof typeof SkillKind]
export const SkillKindLabel: Record<number, string> = {
1: 'Kỹ năng vi tính',
2: 'Ngoại ngữ',
3: 'Kỹ năng khác',
}
export const EmployeeDocumentType = {
IdCard: 1,
Passport: 2,
Degree: 3,
Certificate: 4,
LaborContract: 5,
Other: 99,
} as const
export type EmployeeDocumentType = typeof EmployeeDocumentType[keyof typeof EmployeeDocumentType]
export const EmployeeDocumentTypeLabel: Record<number, string> = {
1: 'CMND/CCCD',
2: 'Hộ chiếu',
3: 'Bằng cấp',
4: 'Chứng chỉ',
5: 'HĐLĐ',
99: 'Khác',
}
// ========== List item (paged) ==========
export type EmployeeListItem = {
id: string
employeeCode: string
userId: string
fullName: string | null
email: string | null
departmentId: string | null
departmentName: string | null
status: number
phone: string | null
hireDate: string | null
createdAt: string
updatedAt: string | null
}
// ========== Satellite read DTOs (inline GetDetail bundle) ==========
export type EmployeeWorkHistoryDto = {
id: string
companyName: string
companyAddress: string | null
industry: string | null
fromDate: string | null
toDate: string | null
jobTitle: string | null
jobDescription: string | null
resignReason: string | null
}
export type EmployeeEducationDto = {
id: string
schoolName: string
major: string | null
degreeLevel: number | null
educationMode: number | null
gradeLevel: number | null
fromDate: string | null
toDate: string | null
certificateIssueDate: string | null
notes: string | null
}
export type EmployeeFamilyRelationDto = {
id: string
fullName: string
relationship: number
birthYear: number | null
occupation: string | null
currentAddress: string | null
phone: string | null
}
export type EmployeeSkillDto = {
id: string
kind: number
name: string
languageId: string | null
level: string | null
}
export type EmployeeDocumentDto = {
id: string
documentType: number
fileName: string
filePath: string
fileSize: number
contentType: string
issueDate: string | null
expiryDate: string | null
notes: string | null
}
// ========== Detail (full + 5 satellite collection) ==========
export type EmployeeDetail = {
id: string
employeeCode: string
userId: string
fullName: string | null
email: string | null
departmentId: string | null
departmentName: string | null
employeeStatus: number
gender: number | null
maritalStatus: number | null
employeeType: number | null
dateOfBirth: string | null
birthPlace: string | null
hometown: string | null
phone: string | null
personalEmail: string | null
internalPhone: string | null
ethnicity: string | null
religion: string | null
nationality: string | null
idCardNumber: string | null
idCardIssueDate: string | null
idCardIssuePlace: string | null
taxCode: string | null
socialInsuranceNumber: string | null
passportNumber: string | null
permanentAddressText: string | null
streetAddressPermanent: string | null
temporaryAddressText: string | null
streetAddressTemporary: string | null
hireDate: string | null
resignDate: string | null
emergencyContactName: string | null
emergencyContactPhone: string | null
emergencyContactAddress: string | null
qualification: string | null
academicTitle: string | null
workLocation: string | null
timekeepingCode: string | null
bankAccount: string | null
bankName: string | null
bankBranch: string | null
heightCm: number | null
weightKg: number | null
bloodType: string | null
baseSalary: number | null
totalSalary: number | null
annualLeaveDays: number | null
remainingLeaveDays: number | null
compensatoryLeaveDays: number | null
seniorityLeaveDays: number | null
socialInsuranceStartDate: string | null
medicalRegistrationPlace: string | null
isCommunistParty: boolean
communistPartyJoinDate: string | null
isYouthUnion: boolean
youthUnionJoinDate: string | null
isTradeUnion: boolean
tradeUnionJoinDate: string | null
photoUrl: string | null
notes: string | null
createdAt: string
updatedAt: string | null
workHistories: EmployeeWorkHistoryDto[]
educations: EmployeeEducationDto[]
familyRelations: EmployeeFamilyRelationDto[]
skills: EmployeeSkillDto[]
documents: EmployeeDocumentDto[]
}

View File

@ -19,6 +19,8 @@ import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWo
import { WorkflowMatrixViewPage } from '@/pages/pe/WorkflowMatrixViewPage'
import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage'
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
import { EmployeesListPage } from '@/pages/hrm/EmployeesListPage'
import { EmployeeCreatePage } from '@/pages/hrm/EmployeeCreatePage'
function App() {
return (
@ -51,6 +53,9 @@ function App() {
<Route path="/budgets" element={<BudgetsListPage />} />
<Route path="/budgets/new" element={<BudgetCreatePage />} />
<Route path="/budgets/:id" element={<BudgetDetailPage />} />
{/* Hồ sơ Nhân sự (Phase 10.1 G-H1 — Mig 34) */}
<Route path="/employees" element={<EmployeesListPage />} />
<Route path="/employees/new" element={<EmployeeCreatePage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route
path="*"

View File

@ -73,6 +73,10 @@ function resolvePath(key: string): string | null {
CatalogMaterials: '/master/catalogs/materials',
CatalogServices: '/master/catalogs/services',
CatalogWorkItems: '/master/catalogs/work-items',
// [Phase 10.1 G-H1 S33 2026-05-26] Module Hồ sơ Nhân sự (Mig 34). LESSON
// Plan CA Hotfix 1 gotcha #50: PHẢI mirror staticMap khi thêm page mới
// — nếu thiếu, MenuLeaf line ~250 `if (!path) return null` → sidebar drop silent.
Hrm_HoSo: '/employees',
}
if (staticMap[key]) return staticMap[key]

View File

@ -19,6 +19,9 @@ export const MenuKeys = {
Permissions: 'Permissions',
PurchaseEvaluations: 'PurchaseEvaluations',
PeWorkflows: 'PeWorkflows',
// Module Hồ sơ Nhân sự (Mig 34 — Phase 10.1 G-H1 Session 33, 2026-05-26)
Hrm: 'Hrm',
HrmHoSo: 'Hrm_HoSo',
} as const
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]

View File

@ -0,0 +1,205 @@
// Page Create Hồ sơ Nhân sự — Header form minimal.
// Phase 10.1 G-H1 Phase 1 (S33 Task 5): chỉ Header field tối thiểu để link
// User → EmployeeProfile. Section còn lại edit sau qua DetailPanel (Phase 1.5).
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { UserPlus } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { Label } from '@/components/ui/Label'
import { Button } from '@/components/ui/Button'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import type { Paged } from '@/types/master'
import { EmployeeStatus, EmployeeStatusLabel, Gender, GenderLabel } from '@/types/employee'
// User dropdown list response (mirror BE UsersController list shape — id/fullName/email subset).
type UserOption = {
id: string
fullName: string | null
email: string
}
type CreateForm = {
userId: string
employeeStatus: string
hireDate: string
dateOfBirth: string
gender: string
phone: string
nationality: string
}
const todayIso = () => new Date().toISOString().slice(0, 10)
const initial: CreateForm = {
userId: '',
employeeStatus: String(EmployeeStatus.Active),
hireDate: todayIso(),
dateOfBirth: '',
gender: '',
phone: '',
nationality: 'Việt Nam',
}
const PHONE_RE = /^0\d{9,10}$/
const isValidPhone = (s: string) => !s || PHONE_RE.test(s.replace(/[\s\-.]/g, ''))
export function EmployeeCreatePage() {
const navigate = useNavigate()
const [form, setForm] = useState<CreateForm>(initial)
// Lấy list user để pick — pageSize lớn cho admin pick dễ.
const users = useQuery({
queryKey: ['users-for-employee-create'],
queryFn: async () =>
(await api.get<Paged<UserOption>>('/users', { params: { page: 1, pageSize: 200 } })).data.items,
})
const create = useMutation({
mutationFn: async () => {
const body = {
userId: form.userId,
employeeStatus: Number(form.employeeStatus),
hireDate: form.hireDate || null,
dateOfBirth: form.dateOfBirth || null,
gender: form.gender ? Number(form.gender) : null,
phone: form.phone.trim() || null,
nationality: form.nationality.trim() || null,
}
return (await api.post<{ id: string }>('/employees', body)).data
},
onSuccess: data => {
toast.success('Đã tạo hồ sơ NV.')
navigate(`/employees?id=${data.id}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
function submit(e: FormEvent) {
e.preventDefault()
if (!form.userId) {
toast.error('Vui lòng chọn user.')
return
}
if (!isValidPhone(form.phone)) {
toast.error('SĐT không hợp lệ (10-11 số, bắt đầu bằng 0).')
return
}
create.mutate()
}
return (
<div className="mx-auto max-w-2xl space-y-4 p-6">
<header className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-brand-600" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">Tạo Hồ Nhân sự mới</h1>
</header>
<form onSubmit={submit} className="space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div>
<Label htmlFor="userId">User <span className="text-red-500">*</span></Label>
<Select
id="userId"
value={form.userId}
onChange={e => setForm(f => ({ ...f, userId: e.target.value }))}
required
>
<option value=""> Chọn user </option>
{(users.data ?? []).map(u => (
<option key={u.id} value={u.id}>
{u.fullName ? `${u.fullName} (${u.email})` : u.email}
</option>
))}
</Select>
<p className="mt-1 text-xs text-slate-500">
Mỗi user chỉ đưc link với 1 hồ NV. Tạo user mới mục System &gt; Users nếu thiếu.
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label htmlFor="employeeStatus">Trạng thái</Label>
<Select
id="employeeStatus"
value={form.employeeStatus}
onChange={e => setForm(f => ({ ...f, employeeStatus: e.target.value }))}
>
<option value={String(EmployeeStatus.Active)}>{EmployeeStatusLabel[EmployeeStatus.Active]}</option>
<option value={String(EmployeeStatus.OnLeave)}>{EmployeeStatusLabel[EmployeeStatus.OnLeave]}</option>
<option value={String(EmployeeStatus.Resigned)}>{EmployeeStatusLabel[EmployeeStatus.Resigned]}</option>
</Select>
</div>
<div>
<Label htmlFor="hireDate">Ngày vào làm</Label>
<Input
id="hireDate"
type="date"
value={form.hireDate}
onChange={e => setForm(f => ({ ...f, hireDate: e.target.value }))}
/>
</div>
<div>
<Label htmlFor="gender">Giới tính</Label>
<Select
id="gender"
value={form.gender}
onChange={e => setForm(f => ({ ...f, gender: e.target.value }))}
>
<option value=""></option>
<option value={String(Gender.Male)}>{GenderLabel[Gender.Male]}</option>
<option value={String(Gender.Female)}>{GenderLabel[Gender.Female]}</option>
<option value={String(Gender.Other)}>{GenderLabel[Gender.Other]}</option>
</Select>
</div>
<div>
<Label htmlFor="dateOfBirth">Ngày sinh</Label>
<Input
id="dateOfBirth"
type="date"
value={form.dateOfBirth}
onChange={e => setForm(f => ({ ...f, dateOfBirth: e.target.value }))}
/>
</div>
<div>
<Label htmlFor="phone">SĐT</Label>
<Input
id="phone"
value={form.phone}
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
placeholder="0912345678"
/>
</div>
<div>
<Label htmlFor="nationality">Quốc tịch</Label>
<Input
id="nationality"
value={form.nationality}
onChange={e => setForm(f => ({ ...f, nationality: e.target.value }))}
/>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-slate-100 pt-4">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={create.isPending}>
Huỷ
</Button>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? 'Đang lưu...' : 'Tạo hồ sơ'}
</Button>
</div>
<p className="text-xs text-slate-400">
Các thông tin chi tiết (giấy tờ, đa chỉ, lương, kỹ năng, ...) thể bổ sung mục Sửa hồ sau khi tạo.
</p>
</form>
</div>
)
}

View File

@ -0,0 +1,573 @@
// List + Detail Hồ sơ Nhân sự (HRM) — 2-panel: filter sidebar | list table + inline detail.
// Phase 10.1 G-H1 Phase 1 ULTRA-MINIMAL scope (S33 Task 5):
// - Read-only mọi section (Edit Header defer Phase 1.5)
// - 6 section render inline trong right panel qua `<details>` HTML native
// - NO separate DetailTabs component, NO satellite CRUD form
// Pattern 16-bis 4-place mirror cross-app (4th reinforcement S33).
// URL params: id (selected), q (search), status, deptId
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { UserCircle2, Search, Plus, X } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/EmptyState'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import type { Paged, Department } from '@/types/master'
import {
EmployeeStatusColor,
EmployeeStatusLabel,
GenderLabel,
MaritalStatusLabel,
EmployeeTypeLabel,
DegreeLevelLabel,
EducationModeLabel,
GradeLevelLabel,
FamilyRelationKindLabel,
SkillKind,
SkillKindLabel,
EmployeeDocumentTypeLabel,
type EmployeeListItem,
type EmployeeDetail,
} from '@/types/employee'
const fmtDate = (s: string | null) => (s ? new Date(s).toLocaleDateString('vi-VN') : '—')
const fmtDateTime = (s: string | null) => (s ? new Date(s).toLocaleString('vi-VN') : '—')
export function EmployeesListPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [sp, setSp] = useSearchParams()
const search = sp.get('q') ?? ''
const statusFilter = sp.get('status') ?? ''
const deptFilter = sp.get('deptId') ?? ''
const selectedId = sp.get('id')
const [localSearch, setLocalSearch] = useState(search)
const departments = useQuery({
queryKey: ['departments-all-hrm'],
queryFn: async () =>
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
})
const list = useQuery({
queryKey: ['employees-list', { search, statusFilter, deptFilter }],
queryFn: async () => {
const res = await api.get<Paged<EmployeeListItem>>('/employees', {
params: {
pageSize: 100,
search: search || undefined,
status: statusFilter || undefined,
departmentId: deptFilter || undefined,
},
})
return res.data
},
})
const detail = useQuery({
queryKey: ['employee-detail', selectedId],
queryFn: async () => (await api.get<EmployeeDetail>(`/employees/${selectedId}`)).data,
enabled: !!selectedId,
})
const del = useMutation({
mutationFn: async (id: string) => api.delete(`/employees/${id}`),
onSuccess: () => {
toast.success('Đã xoá hồ sơ NV.')
setParam('id', null)
qc.invalidateQueries({ queryKey: ['employees-list'] })
},
onError: e => toast.error(getErrorMessage(e)),
})
function setParam(key: string, value: string | null) {
const next = new URLSearchParams(sp)
if (value == null || value === '') next.delete(key)
else next.set(key, value)
setSp(next, { replace: true })
}
function applySearch(e: React.FormEvent) {
e.preventDefault()
setParam('q', localSearch.trim() || null)
}
function resetFilters() {
setLocalSearch('')
setSp(new URLSearchParams(), { replace: true })
}
return (
<div className="grid h-full grid-cols-[340px_1fr] gap-4 p-4">
{/* ========== LEFT PANEL: filter ========== */}
<aside className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<header className="flex items-center gap-2">
<UserCircle2 className="h-5 w-5 text-brand-600" />
<h2 className="text-base font-semibold tracking-tight text-slate-900">Hồ Nhân sự</h2>
</header>
<form onSubmit={applySearch} className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">Tìm kiếm</label>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={localSearch}
onChange={e => setLocalSearch(e.target.value)}
placeholder="Mã NV hoặc họ tên..."
className="pl-8"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">Trạng thái</label>
<Select
value={statusFilter}
onChange={e => setParam('status', e.target.value || null)}
>
<option value="">Tất cả</option>
<option value="1">Đang làm việc</option>
<option value="2">Nghỉ phép</option>
<option value="3">Đã nghỉ việc</option>
</Select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">Phòng ban</label>
<Select
value={deptFilter}
onChange={e => setParam('deptId', e.target.value || null)}
>
<option value="">Tất cả phòng ban</option>
{(departments.data ?? []).map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
</div>
<div className="flex gap-2 pt-1">
<Button type="submit" size="sm" className="flex-1">Tìm</Button>
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
<X className="h-3.5 w-3.5" />
Reset
</Button>
</div>
</form>
<div className="mt-2 border-t border-slate-100 pt-3 text-xs text-slate-500">
Tổng: <span className="font-semibold text-slate-700">{list.data?.total ?? 0}</span> hồ
</div>
</aside>
{/* ========== RIGHT PANEL: list table + selected detail ========== */}
<section className="flex flex-col gap-3 overflow-hidden">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-slate-700">Danh sách nhân viên</h3>
<Button size="sm" onClick={() => navigate('/employees/new')}>
<Plus className="h-4 w-4" />
Tạo mới
</Button>
</div>
<div className="overflow-auto rounded-lg border border-slate-200 bg-white shadow-sm">
{list.isLoading ? (
<div className="p-10 text-center text-sm text-slate-500">Đang tải...</div>
) : !list.data || list.data.items.length === 0 ? (
<EmptyState
icon={UserCircle2}
title="Chưa có hồ sơ NV nào"
description="Bấm 'Tạo mới' để thêm hồ sơ nhân viên đầu tiên."
/>
) : (
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2 text-left font-medium"> NV</th>
<th className="px-3 py-2 text-left font-medium">Họ tên</th>
<th className="px-3 py-2 text-left font-medium">Phòng ban</th>
<th className="px-3 py-2 text-left font-medium">Trạng thái</th>
<th className="px-3 py-2 text-left font-medium">SĐT</th>
</tr>
</thead>
<tbody>
{list.data.items.map(e => (
<tr
key={e.id}
onClick={() => setParam('id', e.id)}
className={cn(
'cursor-pointer border-b border-slate-100 transition hover:bg-slate-50',
selectedId === e.id && 'bg-brand-50 hover:bg-brand-50',
)}
>
<td className="px-3 py-2 font-mono text-xs text-slate-700">{e.employeeCode}</td>
<td className="px-3 py-2 font-medium text-slate-900">{e.fullName ?? '—'}</td>
<td className="px-3 py-2 text-slate-600">{e.departmentName ?? '—'}</td>
<td className="px-3 py-2">
<span className={cn('inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium', EmployeeStatusColor[e.status])}>
{EmployeeStatusLabel[e.status]}
</span>
</td>
<td className="px-3 py-2 text-slate-600">{e.phone ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* ========== Selected detail (6 collapsible section inline) ========== */}
{selectedId && (
<div className="overflow-auto rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
{detail.isLoading ? (
<div className="text-sm text-slate-500">Đang tải chi tiết...</div>
) : !detail.data ? (
<div className="text-sm text-red-600">Không tìm thấy hồ .</div>
) : (
<EmployeeDetailSections detail={detail.data} onDelete={() => del.mutate(detail.data!.id)} />
)}
</div>
)}
</section>
</div>
)
}
// ========== Inline 6-section read-only detail ==========
function EmployeeDetailSections({ detail, onDelete }: { detail: EmployeeDetail; onDelete: () => void }) {
return (
<div className="space-y-3">
{/* Header bar */}
<header className="flex items-start justify-between gap-3 border-b border-slate-200 pb-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 text-slate-400">
{detail.photoUrl ? (
<img src={detail.photoUrl} alt={detail.fullName ?? ''} className="h-12 w-12 rounded-full object-cover" />
) : (
<UserCircle2 className="h-8 w-8" />
)}
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">{detail.fullName ?? '—'}</h3>
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-500">
<span className="font-mono">{detail.employeeCode}</span>
<span></span>
<span className={cn('inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium', EmployeeStatusColor[detail.employeeStatus])}>
{EmployeeStatusLabel[detail.employeeStatus]}
</span>
{detail.departmentName && (
<>
<span></span>
<span>{detail.departmentName}</span>
</>
)}
</div>
</div>
</div>
<Button variant="danger" size="sm" onClick={() => {
if (confirm(`Xoá hồ sơ "${detail.fullName ?? detail.employeeCode}"?`)) onDelete()
}}>
Xoá hồ
</Button>
</header>
{/* Section 1: Cơ bản */}
<Section title="1. Thông tin cơ bản" defaultOpen>
<Grid2>
<Field label="Họ tên" value={detail.fullName} />
<Field label="Mã NV" value={detail.employeeCode} mono />
<Field label="Email" value={detail.email} />
<Field label="Email cá nhân" value={detail.personalEmail} />
<Field label="SĐT" value={detail.phone} />
<Field label="SĐT nội bộ" value={detail.internalPhone} />
<Field label="Ngày sinh" value={fmtDate(detail.dateOfBirth)} />
<Field label="Giới tính" value={detail.gender != null ? GenderLabel[detail.gender] : null} />
<Field label="Tình trạng hôn nhân" value={detail.maritalStatus != null ? MaritalStatusLabel[detail.maritalStatus] : null} />
<Field label="Loại NV" value={detail.employeeType != null ? EmployeeTypeLabel[detail.employeeType] : null} />
<Field label="Quốc tịch" value={detail.nationality} />
<Field label="Dân tộc" value={detail.ethnicity} />
<Field label="Tôn giáo" value={detail.religion} />
<Field label="Nơi sinh" value={detail.birthPlace} />
<Field label="Quê quán" value={detail.hometown} />
<Field label="Phòng ban" value={detail.departmentName} />
<Field label="Vị trí công tác" value={detail.workLocation} />
<Field label="Mã chấm công" value={detail.timekeepingCode} />
<Field label="Ngày vào làm" value={fmtDate(detail.hireDate)} />
<Field label="Ngày nghỉ việc" value={fmtDate(detail.resignDate)} />
</Grid2>
<SubBlock title="Giấy tờ">
<Grid2>
<Field label="CMND/CCCD" value={detail.idCardNumber} mono />
<Field label="Ngày cấp" value={fmtDate(detail.idCardIssueDate)} />
<Field label="Nơi cấp" value={detail.idCardIssuePlace} />
<Field label="Hộ chiếu" value={detail.passportNumber} mono />
<Field label="MST cá nhân" value={detail.taxCode} mono />
<Field label="Số BHXH" value={detail.socialInsuranceNumber} mono />
</Grid2>
</SubBlock>
<SubBlock title="Địa chỉ">
<Grid2>
<Field label="Thường trú" value={detail.permanentAddressText} />
<Field label="Số nhà / Đường (Thường trú)" value={detail.streetAddressPermanent} />
<Field label="Tạm trú" value={detail.temporaryAddressText} />
<Field label="Số nhà / Đường (Tạm trú)" value={detail.streetAddressTemporary} />
</Grid2>
</SubBlock>
<SubBlock title="Liên hệ khẩn cấp">
<Grid2>
<Field label="Họ tên" value={detail.emergencyContactName} />
<Field label="SĐT" value={detail.emergencyContactPhone} />
<Field label="Địa chỉ" value={detail.emergencyContactAddress} />
</Grid2>
</SubBlock>
<SubBlock title="Lương + Phép + BHXH">
<Grid2>
<Field label="Lương cơ bản" value={detail.baseSalary != null ? detail.baseSalary.toLocaleString('vi-VN') + ' đ' : null} />
<Field label="Tổng lương" value={detail.totalSalary != null ? detail.totalSalary.toLocaleString('vi-VN') + ' đ' : null} />
<Field label="Phép năm" value={detail.annualLeaveDays != null ? `${detail.annualLeaveDays} ngày` : null} />
<Field label="Phép còn lại" value={detail.remainingLeaveDays != null ? `${detail.remainingLeaveDays} ngày` : null} />
<Field label="Phép bù" value={detail.compensatoryLeaveDays != null ? `${detail.compensatoryLeaveDays} ngày` : null} />
<Field label="Phép thâm niên" value={detail.seniorityLeaveDays != null ? `${detail.seniorityLeaveDays} ngày` : null} />
<Field label="BHXH bắt đầu" value={fmtDate(detail.socialInsuranceStartDate)} />
<Field label="Nơi đăng ký KCB" value={detail.medicalRegistrationPlace} />
</Grid2>
</SubBlock>
<SubBlock title="Trình độ + Sức khoẻ + Ngân hàng">
<Grid2>
<Field label="Trình độ chuyên môn" value={detail.qualification} />
<Field label="Học hàm" value={detail.academicTitle} />
<Field label="Chiều cao" value={detail.heightCm != null ? `${detail.heightCm} cm` : null} />
<Field label="Cân nặng" value={detail.weightKg != null ? `${detail.weightKg} kg` : null} />
<Field label="Nhóm máu" value={detail.bloodType} />
<Field label="Tài khoản NH" value={detail.bankAccount} mono />
<Field label="Ngân hàng" value={detail.bankName} />
<Field label="Chi nhánh" value={detail.bankBranch} />
</Grid2>
</SubBlock>
<SubBlock title="Đoàn thể">
<Grid2>
<Field label="Đảng viên" value={detail.isCommunistParty ? 'Có' : 'Không'} />
<Field label="Ngày kết nạp Đảng" value={fmtDate(detail.communistPartyJoinDate)} />
<Field label="Đoàn viên" value={detail.isYouthUnion ? 'Có' : 'Không'} />
<Field label="Ngày kết nạp Đoàn" value={fmtDate(detail.youthUnionJoinDate)} />
<Field label="Công đoàn" value={detail.isTradeUnion ? 'Có' : 'Không'} />
<Field label="Ngày kết nạp CĐ" value={fmtDate(detail.tradeUnionJoinDate)} />
</Grid2>
</SubBlock>
{detail.notes && (
<SubBlock title="Ghi chú">
<p className="whitespace-pre-wrap text-sm text-slate-700">{detail.notes}</p>
</SubBlock>
)}
</Section>
{/* Section 2: Công tác */}
<Section title={`2. Quá trình công tác (${detail.workHistories.length})`}>
{detail.workHistories.length === 0 ? (
<EmptyHint text="Chưa có quá trình công tác nào." />
) : (
<div className="space-y-2">
{detail.workHistories.map(w => (
<div key={w.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm">
<div className="flex items-baseline justify-between">
<div className="font-medium text-slate-900">{w.companyName}</div>
<div className="text-xs text-slate-500">
{fmtDate(w.fromDate)} {fmtDate(w.toDate)}
</div>
</div>
{w.jobTitle && <div className="mt-1 text-xs text-slate-600">Chức vụ: {w.jobTitle}</div>}
{w.industry && <div className="text-xs text-slate-600">Ngành: {w.industry}</div>}
{w.companyAddress && <div className="text-xs text-slate-500">Đa chỉ: {w.companyAddress}</div>}
{w.jobDescription && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{w.jobDescription}</div>}
{w.resignReason && <div className="mt-1 text-xs italic text-slate-500"> do nghỉ: {w.resignReason}</div>}
</div>
))}
</div>
)}
</Section>
{/* Section 3: Đào tạo */}
<Section title={`3. Quá trình đào tạo (${detail.educations.length})`}>
{detail.educations.length === 0 ? (
<EmptyHint text="Chưa có quá trình đào tạo nào." />
) : (
<div className="space-y-2">
{detail.educations.map(ed => (
<div key={ed.id} className="rounded-md border border-slate-200 bg-slate-50/50 p-3 text-sm">
<div className="flex items-baseline justify-between">
<div className="font-medium text-slate-900">{ed.schoolName}</div>
<div className="text-xs text-slate-500">
{fmtDate(ed.fromDate)} {fmtDate(ed.toDate)}
</div>
</div>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-600">
{ed.major && <span>Chuyên ngành: {ed.major}</span>}
{ed.degreeLevel != null && <span>Bằng cấp: {DegreeLevelLabel[ed.degreeLevel]}</span>}
{ed.educationMode != null && <span>Hình thức: {EducationModeLabel[ed.educationMode]}</span>}
{ed.gradeLevel != null && <span>Xếp loại: {GradeLevelLabel[ed.gradeLevel]}</span>}
</div>
{ed.certificateIssueDate && <div className="mt-1 text-xs text-slate-500">Ngày cấp bằng: {fmtDate(ed.certificateIssueDate)}</div>}
{ed.notes && <div className="mt-1 whitespace-pre-wrap text-xs text-slate-600">{ed.notes}</div>}
</div>
))}
</div>
)}
</Section>
{/* Section 4: Thân nhân */}
<Section title={`4. Quan hệ gia đình (${detail.familyRelations.length})`}>
{detail.familyRelations.length === 0 ? (
<EmptyHint text="Chưa có thông tin thân nhân." />
) : (
<table className="w-full text-sm">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500">
<tr>
<th className="py-2 pr-3 text-left font-medium">Họ tên</th>
<th className="py-2 pr-3 text-left font-medium">Quan hệ</th>
<th className="py-2 pr-3 text-left font-medium">Năm sinh</th>
<th className="py-2 pr-3 text-left font-medium">Nghề nghiệp</th>
<th className="py-2 pr-3 text-left font-medium">SĐT</th>
</tr>
</thead>
<tbody>
{detail.familyRelations.map(f => (
<tr key={f.id} className="border-b border-slate-100">
<td className="py-2 pr-3 font-medium text-slate-800">{f.fullName}</td>
<td className="py-2 pr-3 text-slate-600">{FamilyRelationKindLabel[f.relationship]}</td>
<td className="py-2 pr-3 text-slate-600">{f.birthYear ?? '—'}</td>
<td className="py-2 pr-3 text-slate-600">{f.occupation ?? '—'}</td>
<td className="py-2 pr-3 text-slate-600">{f.phone ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</Section>
{/* Section 5: Kỹ năng */}
<Section title={`5. Kỹ năng (${detail.skills.length})`}>
{detail.skills.length === 0 ? (
<EmptyHint text="Chưa có thông tin kỹ năng." />
) : (
<div className="space-y-3">
{[SkillKind.Computer, SkillKind.Language, SkillKind.Other].map(kind => {
const group = detail.skills.filter(s => s.kind === kind)
if (group.length === 0) return null
return (
<div key={kind}>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
{SkillKindLabel[kind]}
</div>
<ul className="space-y-1">
{group.map(s => (
<li key={s.id} className="rounded-md border border-slate-200 bg-slate-50/50 px-3 py-2 text-sm">
<div className="flex items-baseline justify-between">
<span className="font-medium text-slate-800">{s.name}</span>
{s.level && <span className="text-xs text-slate-500">{s.level}</span>}
</div>
{s.languageId && <div className="text-xs text-slate-500">: {s.languageId}</div>}
</li>
))}
</ul>
</div>
)
})}
</div>
)}
</Section>
{/* Section 6: Hồ sơ */}
<Section title={`6. Hồ sơ giấy tờ (${detail.documents.length})`}>
{detail.documents.length === 0 ? (
<EmptyHint text="Chưa có hồ sơ giấy tờ nào." />
) : (
<table className="w-full text-sm">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500">
<tr>
<th className="py-2 pr-3 text-left font-medium">Loại</th>
<th className="py-2 pr-3 text-left font-medium">Tên file</th>
<th className="py-2 pr-3 text-left font-medium">Ngày cấp</th>
<th className="py-2 pr-3 text-left font-medium">Ngày hết hạn</th>
</tr>
</thead>
<tbody>
{detail.documents.map(doc => (
<tr key={doc.id} className="border-b border-slate-100">
<td className="py-2 pr-3 text-slate-600">{EmployeeDocumentTypeLabel[doc.documentType]}</td>
<td className="py-2 pr-3">
<a href={doc.filePath} target="_blank" rel="noreferrer" className="text-brand-700 hover:underline">
{doc.fileName}
</a>
</td>
<td className="py-2 pr-3 text-slate-600">{fmtDate(doc.issueDate)}</td>
<td className="py-2 pr-3 text-slate-600">{fmtDate(doc.expiryDate)}</td>
</tr>
))}
</tbody>
</table>
)}
</Section>
<footer className="mt-2 border-t border-slate-100 pt-2 text-xs text-slate-400">
Tạo: {fmtDateTime(detail.createdAt)}
{detail.updatedAt && <> · Cập nhật: {fmtDateTime(detail.updatedAt)}</>}
</footer>
</div>
)
}
// ========== Helpers ==========
function Section({ title, children, defaultOpen }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
return (
<details open={defaultOpen} className="group rounded-md border border-slate-200 bg-white">
<summary className="flex cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 text-sm font-medium text-slate-800 hover:bg-slate-50">
<span>{title}</span>
<span className="text-xs text-slate-400 group-open:rotate-90 transition-transform"></span>
</summary>
<div className="space-y-3 border-t border-slate-100 p-3">{children}</div>
</details>
)
}
function SubBlock({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</div>
{children}
</div>
)
}
function Grid2({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-1 gap-x-4 gap-y-2 md:grid-cols-2">{children}</div>
}
function Field({ label, value, mono }: { label: string; value: string | number | null; mono?: boolean }) {
return (
<div className="text-sm">
<div className="text-xs text-slate-500">{label}</div>
<div className={cn('text-slate-800', mono && 'font-mono text-xs')}>
{value == null || value === '' ? '—' : value}
</div>
</div>
)
}
function EmptyHint({ text }: { text: string }) {
return <div className="py-4 text-center text-sm text-slate-400">{text}</div>
}

View File

@ -0,0 +1,306 @@
// Types cho module Hồ sơ Nhân sự (HRM) — mirror BE Domain.Hrm.Enums + DTOs.
// Phase 10.1 G-H1 (S33) — Pattern 12-bis cross-module FE port PE → Hrm (4th
// reinforcement). TS6 erasableSyntaxOnly cấm enum → const-object pattern bắt buộc.
// ========== Enum mirror BE Domain.Hrm.Enums (10 enum) ==========
export const EmployeeStatus = {
Active: 1,
OnLeave: 2,
Resigned: 3,
} as const
export type EmployeeStatus = typeof EmployeeStatus[keyof typeof EmployeeStatus]
export const EmployeeStatusLabel: Record<number, string> = {
1: 'Đang làm việc',
2: 'Nghỉ phép',
3: 'Đã nghỉ việc',
}
export const EmployeeStatusColor: Record<number, string> = {
1: 'bg-emerald-100 text-emerald-700',
2: 'bg-amber-100 text-amber-700',
3: 'bg-slate-100 text-slate-600',
}
export const Gender = {
Male: 1,
Female: 2,
Other: 3,
} as const
export type Gender = typeof Gender[keyof typeof Gender]
export const GenderLabel: Record<number, string> = {
1: 'Nam',
2: 'Nữ',
3: 'Khác',
}
export const MaritalStatus = {
Single: 1,
Married: 2,
Divorced: 3,
Widowed: 4,
} as const
export type MaritalStatus = typeof MaritalStatus[keyof typeof MaritalStatus]
export const MaritalStatusLabel: Record<number, string> = {
1: 'Độc thân',
2: 'Đã kết hôn',
3: 'Đã ly hôn',
4: 'Goá',
}
export const EmployeeType = {
FullTime: 1,
PartTime: 2,
Intern: 3,
Contractor: 4,
} as const
export type EmployeeType = typeof EmployeeType[keyof typeof EmployeeType]
export const EmployeeTypeLabel: Record<number, string> = {
1: 'Chính thức',
2: 'Bán thời gian',
3: 'Thực tập',
4: 'Khoán việc',
}
export const DegreeLevel = {
College: 1,
Bachelor: 2,
Master: 3,
PhD: 4,
} as const
export type DegreeLevel = typeof DegreeLevel[keyof typeof DegreeLevel]
export const DegreeLevelLabel: Record<number, string> = {
1: 'Cao đẳng',
2: 'Đại học',
3: 'Thạc sĩ',
4: 'Tiến sĩ',
}
export const EducationMode = {
FullTime: 1,
PartTime: 2,
Distance: 3,
} as const
export type EducationMode = typeof EducationMode[keyof typeof EducationMode]
export const EducationModeLabel: Record<number, string> = {
1: 'Chính quy',
2: 'Tại chức',
3: 'Từ xa',
}
export const GradeLevel = {
Average: 1,
Good: 2,
Excellent: 3,
} as const
export type GradeLevel = typeof GradeLevel[keyof typeof GradeLevel]
export const GradeLevelLabel: Record<number, string> = {
1: 'Trung bình',
2: 'Khá',
3: 'Giỏi',
}
export const FamilyRelationKind = {
Father: 1,
Mother: 2,
Spouse: 3,
Child: 4,
Sibling: 5,
Other: 99,
} as const
export type FamilyRelationKind = typeof FamilyRelationKind[keyof typeof FamilyRelationKind]
export const FamilyRelationKindLabel: Record<number, string> = {
1: 'Cha',
2: 'Mẹ',
3: 'Vợ/Chồng',
4: 'Con',
5: 'Anh/Chị/Em ruột',
99: 'Khác',
}
export const SkillKind = {
Computer: 1,
Language: 2,
Other: 3,
} as const
export type SkillKind = typeof SkillKind[keyof typeof SkillKind]
export const SkillKindLabel: Record<number, string> = {
1: 'Kỹ năng vi tính',
2: 'Ngoại ngữ',
3: 'Kỹ năng khác',
}
export const EmployeeDocumentType = {
IdCard: 1,
Passport: 2,
Degree: 3,
Certificate: 4,
LaborContract: 5,
Other: 99,
} as const
export type EmployeeDocumentType = typeof EmployeeDocumentType[keyof typeof EmployeeDocumentType]
export const EmployeeDocumentTypeLabel: Record<number, string> = {
1: 'CMND/CCCD',
2: 'Hộ chiếu',
3: 'Bằng cấp',
4: 'Chứng chỉ',
5: 'HĐLĐ',
99: 'Khác',
}
// ========== List item (paged) ==========
export type EmployeeListItem = {
id: string
employeeCode: string
userId: string
fullName: string | null
email: string | null
departmentId: string | null
departmentName: string | null
status: number
phone: string | null
hireDate: string | null
createdAt: string
updatedAt: string | null
}
// ========== Satellite read DTOs (inline GetDetail bundle) ==========
export type EmployeeWorkHistoryDto = {
id: string
companyName: string
companyAddress: string | null
industry: string | null
fromDate: string | null
toDate: string | null
jobTitle: string | null
jobDescription: string | null
resignReason: string | null
}
export type EmployeeEducationDto = {
id: string
schoolName: string
major: string | null
degreeLevel: number | null
educationMode: number | null
gradeLevel: number | null
fromDate: string | null
toDate: string | null
certificateIssueDate: string | null
notes: string | null
}
export type EmployeeFamilyRelationDto = {
id: string
fullName: string
relationship: number
birthYear: number | null
occupation: string | null
currentAddress: string | null
phone: string | null
}
export type EmployeeSkillDto = {
id: string
kind: number
name: string
languageId: string | null
level: string | null
}
export type EmployeeDocumentDto = {
id: string
documentType: number
fileName: string
filePath: string
fileSize: number
contentType: string
issueDate: string | null
expiryDate: string | null
notes: string | null
}
// ========== Detail (full + 5 satellite collection) ==========
export type EmployeeDetail = {
id: string
employeeCode: string
userId: string
fullName: string | null
email: string | null
departmentId: string | null
departmentName: string | null
employeeStatus: number
gender: number | null
maritalStatus: number | null
employeeType: number | null
dateOfBirth: string | null
birthPlace: string | null
hometown: string | null
phone: string | null
personalEmail: string | null
internalPhone: string | null
ethnicity: string | null
religion: string | null
nationality: string | null
idCardNumber: string | null
idCardIssueDate: string | null
idCardIssuePlace: string | null
taxCode: string | null
socialInsuranceNumber: string | null
passportNumber: string | null
permanentAddressText: string | null
streetAddressPermanent: string | null
temporaryAddressText: string | null
streetAddressTemporary: string | null
hireDate: string | null
resignDate: string | null
emergencyContactName: string | null
emergencyContactPhone: string | null
emergencyContactAddress: string | null
qualification: string | null
academicTitle: string | null
workLocation: string | null
timekeepingCode: string | null
bankAccount: string | null
bankName: string | null
bankBranch: string | null
heightCm: number | null
weightKg: number | null
bloodType: string | null
baseSalary: number | null
totalSalary: number | null
annualLeaveDays: number | null
remainingLeaveDays: number | null
compensatoryLeaveDays: number | null
seniorityLeaveDays: number | null
socialInsuranceStartDate: string | null
medicalRegistrationPlace: string | null
isCommunistParty: boolean
communistPartyJoinDate: string | null
isYouthUnion: boolean
youthUnionJoinDate: string | null
isTradeUnion: boolean
tradeUnionJoinDate: string | null
photoUrl: string | null
notes: string | null
createdAt: string
updatedAt: string | null
workHistories: EmployeeWorkHistoryDto[]
educations: EmployeeEducationDto[]
familyRelations: EmployeeFamilyRelationDto[]
skills: EmployeeSkillDto[]
documents: EmployeeDocumentDto[]
}

View File

@ -0,0 +1,62 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Hrm;
using SolutionErp.Application.Hrm.Dtos;
using SolutionErp.Domain.Hrm;
namespace SolutionErp.Api.Controllers;
// Phase 10.1 G-H1 Task 4 (S33) — REST endpoint cho Hồ sơ Nhân sự.
// 5 main endpoint Phase 1 minimal: List / Get / Create / Update / Delete.
// Satellite endpoint (WorkHistory/Education/FamilyRelation/Skill/Document
// CRUD) DEFER Phase 1.5.
//
// Class-level [Authorize] only — em main Task 6 wire per-action policy
// "Hrm_HoSo_View/Create/Edit/Delete" sau khi seed MenuKeys.
[ApiController]
[Route("api/employees")]
[Authorize]
public class EmployeesController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PagedResult<EmployeeProfileListItemDto>>> List(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? search = null,
[FromQuery] bool sortDesc = true,
[FromQuery] EmployeeStatus? status = null,
[FromQuery] Guid? departmentId = null,
CancellationToken ct = default)
=> Ok(await mediator.Send(new ListEmployeesQuery(status, departmentId)
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
[HttpGet("{id:guid}")]
public async Task<ActionResult<EmployeeProfileDetailDto>> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetEmployeeProfileQuery(id), ct));
[HttpPost]
public async Task<ActionResult<object>> Create(
[FromBody] CreateEmployeeProfileCommand cmd, CancellationToken ct)
{
var id = await mediator.Send(cmd, ct);
return CreatedAtAction(nameof(Get), new { id }, new { id });
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(
Guid id, [FromBody] UpdateEmployeeProfileCommand cmd, CancellationToken ct)
{
if (id != cmd.Id) return BadRequest(new { detail = "ID không khớp" });
await mediator.Send(cmd, ct);
return NoContent();
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await mediator.Send(new DeleteEmployeeProfileCommand(id), ct);
return NoContent();
}
}

View File

@ -0,0 +1,151 @@
using SolutionErp.Domain.Hrm;
namespace SolutionErp.Application.Hrm.Dtos;
// Phase 10.1 G-H1 Task 4 (S33) — DTO bundle cho Hồ sơ Nhân sự CQRS.
// 1 list item + 1 detail full + 5 satellite read DTO (inline GetDetail).
// Satellite WRITE DTO defer Phase 1.5 — Task 4 chỉ read.
// ========== List item (paged) ==========
// JOIN Users + Departments LEFT (User.DepartmentId nullable per Identity entity).
// FullName + Email từ User; DepartmentId + DepartmentName từ User → Department.
public record EmployeeProfileListItemDto(
Guid Id,
string EmployeeCode,
Guid UserId,
string? FullName,
string? Email,
Guid? DepartmentId,
string? DepartmentName,
EmployeeStatus Status,
string? Phone,
DateOnly? HireDate,
DateTime CreatedAt,
DateTime? UpdatedAt);
// ========== Detail (full + 5 satellite collection) ==========
public record EmployeeProfileDetailDto(
Guid Id,
string EmployeeCode,
Guid UserId,
string? FullName,
string? Email,
Guid? DepartmentId,
string? DepartmentName,
EmployeeStatus EmployeeStatus,
Gender? Gender,
MaritalStatus? MaritalStatus,
EmployeeType? EmployeeType,
DateOnly? DateOfBirth,
string? BirthPlace,
string? Hometown,
string? Phone,
string? PersonalEmail,
string? InternalPhone,
string? Ethnicity,
string? Religion,
string? Nationality,
string? IdCardNumber,
DateOnly? IdCardIssueDate,
string? IdCardIssuePlace,
string? TaxCode,
string? SocialInsuranceNumber,
string? PassportNumber,
string? PermanentAddressText,
string? StreetAddressPermanent,
string? TemporaryAddressText,
string? StreetAddressTemporary,
DateOnly? HireDate,
DateOnly? ResignDate,
string? EmergencyContactName,
string? EmergencyContactPhone,
string? EmergencyContactAddress,
string? Qualification,
string? AcademicTitle,
string? WorkLocation,
string? TimekeepingCode,
string? BankAccount,
string? BankName,
string? BankBranch,
int? HeightCm,
int? WeightKg,
string? BloodType,
decimal? BaseSalary,
decimal? TotalSalary,
decimal? AnnualLeaveDays,
decimal? RemainingLeaveDays,
decimal? CompensatoryLeaveDays,
decimal? SeniorityLeaveDays,
DateOnly? SocialInsuranceStartDate,
string? MedicalRegistrationPlace,
bool IsCommunistParty,
DateOnly? CommunistPartyJoinDate,
bool IsYouthUnion,
DateOnly? YouthUnionJoinDate,
bool IsTradeUnion,
DateOnly? TradeUnionJoinDate,
string? PhotoUrl,
string? Notes,
DateTime CreatedAt,
DateTime? UpdatedAt,
List<EmployeeWorkHistoryDto> WorkHistories,
List<EmployeeEducationDto> Educations,
List<EmployeeFamilyRelationDto> FamilyRelations,
List<EmployeeSkillDto> Skills,
List<EmployeeDocumentDto> Documents);
// ========== Satellite read DTOs (inline GetDetail bundle) ==========
public record EmployeeWorkHistoryDto(
Guid Id,
string CompanyName,
string? CompanyAddress,
string? Industry,
DateOnly? FromDate,
DateOnly? ToDate,
string? JobTitle,
string? JobDescription,
string? ResignReason);
public record EmployeeEducationDto(
Guid Id,
string SchoolName,
string? Major,
DegreeLevel? DegreeLevel,
EducationMode? EducationMode,
GradeLevel? GradeLevel,
DateOnly? FromDate,
DateOnly? ToDate,
DateOnly? CertificateIssueDate,
string? Notes);
public record EmployeeFamilyRelationDto(
Guid Id,
string FullName,
FamilyRelationKind Relationship,
int? BirthYear,
string? Occupation,
string? CurrentAddress,
string? Phone);
// Polymorphic — Kind discriminator. Computer/Language/Other (semantic xem
// EmployeeSkill.cs comment header).
public record EmployeeSkillDto(
Guid Id,
SkillKind Kind,
string Name,
string? LanguageId,
string? Level);
public record EmployeeDocumentDto(
Guid Id,
EmployeeDocumentType DocumentType,
string FileName,
string FilePath,
long FileSize,
string ContentType,
DateOnly? IssueDate,
DateOnly? ExpiryDate,
string? Notes);

View File

@ -0,0 +1,610 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Hrm.Dtos;
using SolutionErp.Application.Hrm.Services;
using SolutionErp.Domain.Hrm;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Application.Hrm;
// Phase 10.1 G-H1 Task 4 (S33) — Hồ sơ Nhân sự CQRS bundle.
// 5 main endpoint Phase 1 minimal: Create / Update / Delete (soft) / Get / List.
// Satellite CRUD endpoint (WorkHistory/Education/FamilyRelation/Skill/Document)
// DEFER Phase 1.5 — GetDetail trả về 5 satellite collection inline cho FE Tab
// hiển thị nháy mắt. Bro UAT có thể request mở satellite write Phase 1.5 sau.
//
// Pattern mirror PurchaseEvaluationFeatures.cs (Pattern 12-bis cross-module
// cookie-cutter PE → Hrm — 4th reinforcement post Plan B Chunk C S29).
// ========== CREATE ==========
public record CreateEmployeeProfileCommand(
Guid UserId,
EmployeeStatus EmployeeStatus = EmployeeStatus.Active,
Gender? Gender = null,
MaritalStatus? MaritalStatus = null,
EmployeeType? EmployeeType = null,
DateOnly? DateOfBirth = null,
string? BirthPlace = null,
string? Hometown = null,
string? Phone = null,
string? PersonalEmail = null,
string? InternalPhone = null,
string? Ethnicity = null,
string? Religion = null,
string? Nationality = null,
string? IdCardNumber = null,
DateOnly? IdCardIssueDate = null,
string? IdCardIssuePlace = null,
string? TaxCode = null,
string? SocialInsuranceNumber = null,
string? PassportNumber = null,
string? PermanentAddressText = null,
string? StreetAddressPermanent = null,
string? TemporaryAddressText = null,
string? StreetAddressTemporary = null,
DateOnly? HireDate = null,
string? EmergencyContactName = null,
string? EmergencyContactPhone = null,
string? EmergencyContactAddress = null,
string? Qualification = null,
string? AcademicTitle = null,
string? WorkLocation = null,
string? TimekeepingCode = null,
string? BankAccount = null,
string? BankName = null,
string? BankBranch = null,
int? HeightCm = null,
int? WeightKg = null,
string? BloodType = null,
decimal? BaseSalary = null,
decimal? TotalSalary = null,
decimal? AnnualLeaveDays = null,
DateOnly? SocialInsuranceStartDate = null,
string? MedicalRegistrationPlace = null,
bool IsCommunistParty = false,
DateOnly? CommunistPartyJoinDate = null,
bool IsYouthUnion = false,
DateOnly? YouthUnionJoinDate = null,
bool IsTradeUnion = false,
DateOnly? TradeUnionJoinDate = null,
string? PhotoUrl = null,
string? Notes = null) : IRequest<Guid>;
public class CreateEmployeeProfileCommandValidator : AbstractValidator<CreateEmployeeProfileCommand>
{
public CreateEmployeeProfileCommandValidator()
{
RuleFor(x => x.UserId).NotEmpty();
RuleFor(x => x.EmployeeStatus).IsInEnum();
// Liên hệ
RuleFor(x => x.Phone).MaximumLength(20);
RuleFor(x => x.PersonalEmail).MaximumLength(200).EmailAddress()
.When(x => !string.IsNullOrWhiteSpace(x.PersonalEmail));
RuleFor(x => x.InternalPhone).MaximumLength(20);
// Cá nhân
RuleFor(x => x.BirthPlace).MaximumLength(200);
RuleFor(x => x.Hometown).MaximumLength(200);
RuleFor(x => x.Ethnicity).MaximumLength(50);
RuleFor(x => x.Religion).MaximumLength(50);
RuleFor(x => x.Nationality).MaximumLength(50);
// Giấy tờ
RuleFor(x => x.IdCardNumber).MaximumLength(20);
RuleFor(x => x.IdCardIssuePlace).MaximumLength(200);
RuleFor(x => x.TaxCode).MaximumLength(20);
RuleFor(x => x.SocialInsuranceNumber).MaximumLength(20);
RuleFor(x => x.PassportNumber).MaximumLength(20);
// Địa chỉ
RuleFor(x => x.PermanentAddressText).MaximumLength(500);
RuleFor(x => x.StreetAddressPermanent).MaximumLength(200);
RuleFor(x => x.TemporaryAddressText).MaximumLength(500);
RuleFor(x => x.StreetAddressTemporary).MaximumLength(200);
// Khẩn cấp
RuleFor(x => x.EmergencyContactName).MaximumLength(200);
RuleFor(x => x.EmergencyContactPhone).MaximumLength(20);
RuleFor(x => x.EmergencyContactAddress).MaximumLength(500);
// Trình độ + vị trí
RuleFor(x => x.Qualification).MaximumLength(200);
RuleFor(x => x.AcademicTitle).MaximumLength(200);
RuleFor(x => x.WorkLocation).MaximumLength(200);
RuleFor(x => x.TimekeepingCode).MaximumLength(50);
// Ngân hàng
RuleFor(x => x.BankAccount).MaximumLength(50);
RuleFor(x => x.BankName).MaximumLength(200);
RuleFor(x => x.BankBranch).MaximumLength(200);
// Sức khoẻ
RuleFor(x => x.BloodType).MaximumLength(5);
// Lương + Phép (decimal — không âm)
RuleFor(x => x.BaseSalary).GreaterThanOrEqualTo(0).When(x => x.BaseSalary.HasValue);
RuleFor(x => x.TotalSalary).GreaterThanOrEqualTo(0).When(x => x.TotalSalary.HasValue);
RuleFor(x => x.AnnualLeaveDays).GreaterThanOrEqualTo(0).When(x => x.AnnualLeaveDays.HasValue);
// BHXH
RuleFor(x => x.MedicalRegistrationPlace).MaximumLength(200);
// Khác
RuleFor(x => x.PhotoUrl).MaximumLength(500);
RuleFor(x => x.Notes).MaximumLength(2000);
}
}
public class CreateEmployeeProfileCommandHandler(
IApplicationDbContext db,
IEmployeeCodeGenerator codeGen,
UserManager<User> userManager) : IRequestHandler<CreateEmployeeProfileCommand, Guid>
{
public async Task<Guid> Handle(CreateEmployeeProfileCommand request, CancellationToken ct)
{
// Verify User tồn tại (1-1 link User.Id).
var user = await userManager.FindByIdAsync(request.UserId.ToString())
?? throw new NotFoundException("User", request.UserId);
// Verify NO existing EmployeeProfile cho UserId này (UNIQUE 1-1 ràng buộc Mig 34).
// Check cả soft-deleted để admin biết user đã từng có profile (re-activate Phase 1.5).
var existing = await db.EmployeeProfiles.AsNoTracking()
.FirstOrDefaultAsync(x => x.UserId == request.UserId, ct);
if (existing != null)
throw new ConflictException(existing.IsDeleted
? $"User {user.UserName} đã có hồ sơ NV (đã xoá mềm). Cần khôi phục thay vì tạo mới."
: $"User {user.UserName} đã có hồ sơ NV — mỗi user chỉ được 1 hồ sơ.");
// Atomic MaNhanVien sequence — format NV/{YYYY}/{Seq:D4} (mirror PE pattern).
var employeeCode = await codeGen.GenerateAsync(ct);
var entity = new EmployeeProfile
{
UserId = request.UserId,
EmployeeCode = employeeCode,
EmployeeStatus = request.EmployeeStatus,
Gender = request.Gender,
MaritalStatus = request.MaritalStatus,
EmployeeType = request.EmployeeType,
DateOfBirth = request.DateOfBirth,
BirthPlace = request.BirthPlace,
Hometown = request.Hometown,
Phone = request.Phone,
PersonalEmail = request.PersonalEmail,
InternalPhone = request.InternalPhone,
Ethnicity = request.Ethnicity,
Religion = request.Religion,
Nationality = request.Nationality ?? "Việt Nam",
IdCardNumber = request.IdCardNumber,
IdCardIssueDate = request.IdCardIssueDate,
IdCardIssuePlace = request.IdCardIssuePlace,
TaxCode = request.TaxCode,
SocialInsuranceNumber = request.SocialInsuranceNumber,
PassportNumber = request.PassportNumber,
PermanentAddressText = request.PermanentAddressText,
StreetAddressPermanent = request.StreetAddressPermanent,
TemporaryAddressText = request.TemporaryAddressText,
StreetAddressTemporary = request.StreetAddressTemporary,
HireDate = request.HireDate,
EmergencyContactName = request.EmergencyContactName,
EmergencyContactPhone = request.EmergencyContactPhone,
EmergencyContactAddress = request.EmergencyContactAddress,
Qualification = request.Qualification,
AcademicTitle = request.AcademicTitle,
WorkLocation = request.WorkLocation,
TimekeepingCode = request.TimekeepingCode,
BankAccount = request.BankAccount,
BankName = request.BankName,
BankBranch = request.BankBranch,
HeightCm = request.HeightCm,
WeightKg = request.WeightKg,
BloodType = request.BloodType,
BaseSalary = request.BaseSalary,
TotalSalary = request.TotalSalary,
AnnualLeaveDays = request.AnnualLeaveDays,
SocialInsuranceStartDate = request.SocialInsuranceStartDate,
MedicalRegistrationPlace = request.MedicalRegistrationPlace,
IsCommunistParty = request.IsCommunistParty,
CommunistPartyJoinDate = request.CommunistPartyJoinDate,
IsYouthUnion = request.IsYouthUnion,
YouthUnionJoinDate = request.YouthUnionJoinDate,
IsTradeUnion = request.IsTradeUnion,
TradeUnionJoinDate = request.TradeUnionJoinDate,
PhotoUrl = request.PhotoUrl,
Notes = request.Notes,
};
db.EmployeeProfiles.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
// ========== UPDATE ==========
// UserId + EmployeeCode immutable — admin KHÔNG đổi link user lẫn mã NV.
// Field mutable: toàn bộ phần còn lại Create command + ResignDate + 3 leave fields
// extra (RemainingLeaveDays/CompensatoryLeaveDays/SeniorityLeaveDays) cho admin chỉnh.
public record UpdateEmployeeProfileCommand(
Guid Id,
EmployeeStatus EmployeeStatus,
Gender? Gender,
MaritalStatus? MaritalStatus,
EmployeeType? EmployeeType,
DateOnly? DateOfBirth,
string? BirthPlace,
string? Hometown,
string? Phone,
string? PersonalEmail,
string? InternalPhone,
string? Ethnicity,
string? Religion,
string? Nationality,
string? IdCardNumber,
DateOnly? IdCardIssueDate,
string? IdCardIssuePlace,
string? TaxCode,
string? SocialInsuranceNumber,
string? PassportNumber,
string? PermanentAddressText,
string? StreetAddressPermanent,
string? TemporaryAddressText,
string? StreetAddressTemporary,
DateOnly? HireDate,
DateOnly? ResignDate,
string? EmergencyContactName,
string? EmergencyContactPhone,
string? EmergencyContactAddress,
string? Qualification,
string? AcademicTitle,
string? WorkLocation,
string? TimekeepingCode,
string? BankAccount,
string? BankName,
string? BankBranch,
int? HeightCm,
int? WeightKg,
string? BloodType,
decimal? BaseSalary,
decimal? TotalSalary,
decimal? AnnualLeaveDays,
decimal? RemainingLeaveDays,
decimal? CompensatoryLeaveDays,
decimal? SeniorityLeaveDays,
DateOnly? SocialInsuranceStartDate,
string? MedicalRegistrationPlace,
bool IsCommunistParty,
DateOnly? CommunistPartyJoinDate,
bool IsYouthUnion,
DateOnly? YouthUnionJoinDate,
bool IsTradeUnion,
DateOnly? TradeUnionJoinDate,
string? PhotoUrl,
string? Notes) : IRequest;
public class UpdateEmployeeProfileCommandValidator : AbstractValidator<UpdateEmployeeProfileCommand>
{
public UpdateEmployeeProfileCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.EmployeeStatus).IsInEnum();
RuleFor(x => x.Phone).MaximumLength(20);
RuleFor(x => x.PersonalEmail).MaximumLength(200).EmailAddress()
.When(x => !string.IsNullOrWhiteSpace(x.PersonalEmail));
RuleFor(x => x.InternalPhone).MaximumLength(20);
RuleFor(x => x.BirthPlace).MaximumLength(200);
RuleFor(x => x.Hometown).MaximumLength(200);
RuleFor(x => x.Ethnicity).MaximumLength(50);
RuleFor(x => x.Religion).MaximumLength(50);
RuleFor(x => x.Nationality).MaximumLength(50);
RuleFor(x => x.IdCardNumber).MaximumLength(20);
RuleFor(x => x.IdCardIssuePlace).MaximumLength(200);
RuleFor(x => x.TaxCode).MaximumLength(20);
RuleFor(x => x.SocialInsuranceNumber).MaximumLength(20);
RuleFor(x => x.PassportNumber).MaximumLength(20);
RuleFor(x => x.PermanentAddressText).MaximumLength(500);
RuleFor(x => x.StreetAddressPermanent).MaximumLength(200);
RuleFor(x => x.TemporaryAddressText).MaximumLength(500);
RuleFor(x => x.StreetAddressTemporary).MaximumLength(200);
RuleFor(x => x.EmergencyContactName).MaximumLength(200);
RuleFor(x => x.EmergencyContactPhone).MaximumLength(20);
RuleFor(x => x.EmergencyContactAddress).MaximumLength(500);
RuleFor(x => x.Qualification).MaximumLength(200);
RuleFor(x => x.AcademicTitle).MaximumLength(200);
RuleFor(x => x.WorkLocation).MaximumLength(200);
RuleFor(x => x.TimekeepingCode).MaximumLength(50);
RuleFor(x => x.BankAccount).MaximumLength(50);
RuleFor(x => x.BankName).MaximumLength(200);
RuleFor(x => x.BankBranch).MaximumLength(200);
RuleFor(x => x.BloodType).MaximumLength(5);
RuleFor(x => x.BaseSalary).GreaterThanOrEqualTo(0).When(x => x.BaseSalary.HasValue);
RuleFor(x => x.TotalSalary).GreaterThanOrEqualTo(0).When(x => x.TotalSalary.HasValue);
RuleFor(x => x.AnnualLeaveDays).GreaterThanOrEqualTo(0).When(x => x.AnnualLeaveDays.HasValue);
RuleFor(x => x.RemainingLeaveDays).GreaterThanOrEqualTo(0).When(x => x.RemainingLeaveDays.HasValue);
RuleFor(x => x.CompensatoryLeaveDays).GreaterThanOrEqualTo(0).When(x => x.CompensatoryLeaveDays.HasValue);
RuleFor(x => x.SeniorityLeaveDays).GreaterThanOrEqualTo(0).When(x => x.SeniorityLeaveDays.HasValue);
RuleFor(x => x.MedicalRegistrationPlace).MaximumLength(200);
RuleFor(x => x.PhotoUrl).MaximumLength(500);
RuleFor(x => x.Notes).MaximumLength(2000);
}
}
public class UpdateEmployeeProfileCommandHandler(
IApplicationDbContext db) : IRequestHandler<UpdateEmployeeProfileCommand>
{
public async Task Handle(UpdateEmployeeProfileCommand request, CancellationToken ct)
{
var entity = await db.EmployeeProfiles
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct)
?? throw new NotFoundException("EmployeeProfile", request.Id);
// UserId + EmployeeCode immutable — bỏ qua mọi attempt update.
entity.EmployeeStatus = request.EmployeeStatus;
entity.Gender = request.Gender;
entity.MaritalStatus = request.MaritalStatus;
entity.EmployeeType = request.EmployeeType;
entity.DateOfBirth = request.DateOfBirth;
entity.BirthPlace = request.BirthPlace;
entity.Hometown = request.Hometown;
entity.Phone = request.Phone;
entity.PersonalEmail = request.PersonalEmail;
entity.InternalPhone = request.InternalPhone;
entity.Ethnicity = request.Ethnicity;
entity.Religion = request.Religion;
entity.Nationality = request.Nationality ?? "Việt Nam";
entity.IdCardNumber = request.IdCardNumber;
entity.IdCardIssueDate = request.IdCardIssueDate;
entity.IdCardIssuePlace = request.IdCardIssuePlace;
entity.TaxCode = request.TaxCode;
entity.SocialInsuranceNumber = request.SocialInsuranceNumber;
entity.PassportNumber = request.PassportNumber;
entity.PermanentAddressText = request.PermanentAddressText;
entity.StreetAddressPermanent = request.StreetAddressPermanent;
entity.TemporaryAddressText = request.TemporaryAddressText;
entity.StreetAddressTemporary = request.StreetAddressTemporary;
entity.HireDate = request.HireDate;
entity.ResignDate = request.ResignDate;
entity.EmergencyContactName = request.EmergencyContactName;
entity.EmergencyContactPhone = request.EmergencyContactPhone;
entity.EmergencyContactAddress = request.EmergencyContactAddress;
entity.Qualification = request.Qualification;
entity.AcademicTitle = request.AcademicTitle;
entity.WorkLocation = request.WorkLocation;
entity.TimekeepingCode = request.TimekeepingCode;
entity.BankAccount = request.BankAccount;
entity.BankName = request.BankName;
entity.BankBranch = request.BankBranch;
entity.HeightCm = request.HeightCm;
entity.WeightKg = request.WeightKg;
entity.BloodType = request.BloodType;
entity.BaseSalary = request.BaseSalary;
entity.TotalSalary = request.TotalSalary;
entity.AnnualLeaveDays = request.AnnualLeaveDays;
entity.RemainingLeaveDays = request.RemainingLeaveDays;
entity.CompensatoryLeaveDays = request.CompensatoryLeaveDays;
entity.SeniorityLeaveDays = request.SeniorityLeaveDays;
entity.SocialInsuranceStartDate = request.SocialInsuranceStartDate;
entity.MedicalRegistrationPlace = request.MedicalRegistrationPlace;
entity.IsCommunistParty = request.IsCommunistParty;
entity.CommunistPartyJoinDate = request.CommunistPartyJoinDate;
entity.IsYouthUnion = request.IsYouthUnion;
entity.YouthUnionJoinDate = request.YouthUnionJoinDate;
entity.IsTradeUnion = request.IsTradeUnion;
entity.TradeUnionJoinDate = request.TradeUnionJoinDate;
entity.PhotoUrl = request.PhotoUrl;
entity.Notes = request.Notes;
await db.SaveChangesAsync(ct);
}
}
// ========== DELETE (soft) ==========
public record DeleteEmployeeProfileCommand(Guid Id) : IRequest;
public class DeleteEmployeeProfileCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<DeleteEmployeeProfileCommand>
{
public async Task Handle(DeleteEmployeeProfileCommand request, CancellationToken ct)
{
var entity = await db.EmployeeProfiles
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct)
?? throw new NotFoundException("EmployeeProfile", request.Id);
entity.IsDeleted = true;
entity.DeletedAt = DateTime.UtcNow;
entity.DeletedBy = currentUser.UserId;
await db.SaveChangesAsync(ct);
}
}
// ========== GET DETAIL ==========
public record GetEmployeeProfileQuery(Guid Id) : IRequest<EmployeeProfileDetailDto>;
public class GetEmployeeProfileQueryHandler(
IApplicationDbContext db) : IRequestHandler<GetEmployeeProfileQuery, EmployeeProfileDetailDto>
{
public async Task<EmployeeProfileDetailDto> Handle(GetEmployeeProfileQuery request, CancellationToken ct)
{
var entity = await db.EmployeeProfiles.AsNoTracking()
.Include(x => x.WorkHistories)
.Include(x => x.Educations)
.Include(x => x.FamilyRelations)
.Include(x => x.Skills)
.Include(x => x.Documents)
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, ct)
?? throw new NotFoundException("EmployeeProfile", request.Id);
// JOIN User + Department LEFT (Department nullable per User entity).
var userInfo = await (
from u in db.Users.AsNoTracking()
where u.Id == entity.UserId
join d in db.Departments.AsNoTracking() on u.DepartmentId equals d.Id into dj
from d in dj.DefaultIfEmpty()
select new { u.FullName, u.Email, u.DepartmentId, DepartmentName = d != null ? d.Name : null })
.FirstOrDefaultAsync(ct);
return new EmployeeProfileDetailDto(
entity.Id,
entity.EmployeeCode,
entity.UserId,
userInfo?.FullName,
userInfo?.Email,
userInfo?.DepartmentId,
userInfo?.DepartmentName,
entity.EmployeeStatus,
entity.Gender,
entity.MaritalStatus,
entity.EmployeeType,
entity.DateOfBirth,
entity.BirthPlace,
entity.Hometown,
entity.Phone,
entity.PersonalEmail,
entity.InternalPhone,
entity.Ethnicity,
entity.Religion,
entity.Nationality,
entity.IdCardNumber,
entity.IdCardIssueDate,
entity.IdCardIssuePlace,
entity.TaxCode,
entity.SocialInsuranceNumber,
entity.PassportNumber,
entity.PermanentAddressText,
entity.StreetAddressPermanent,
entity.TemporaryAddressText,
entity.StreetAddressTemporary,
entity.HireDate,
entity.ResignDate,
entity.EmergencyContactName,
entity.EmergencyContactPhone,
entity.EmergencyContactAddress,
entity.Qualification,
entity.AcademicTitle,
entity.WorkLocation,
entity.TimekeepingCode,
entity.BankAccount,
entity.BankName,
entity.BankBranch,
entity.HeightCm,
entity.WeightKg,
entity.BloodType,
entity.BaseSalary,
entity.TotalSalary,
entity.AnnualLeaveDays,
entity.RemainingLeaveDays,
entity.CompensatoryLeaveDays,
entity.SeniorityLeaveDays,
entity.SocialInsuranceStartDate,
entity.MedicalRegistrationPlace,
entity.IsCommunistParty,
entity.CommunistPartyJoinDate,
entity.IsYouthUnion,
entity.YouthUnionJoinDate,
entity.IsTradeUnion,
entity.TradeUnionJoinDate,
entity.PhotoUrl,
entity.Notes,
entity.CreatedAt,
entity.UpdatedAt,
entity.WorkHistories.Select(w => new EmployeeWorkHistoryDto(
w.Id, w.CompanyName, w.CompanyAddress, w.Industry,
w.FromDate, w.ToDate, w.JobTitle, w.JobDescription, w.ResignReason)).ToList(),
entity.Educations.Select(ed => new EmployeeEducationDto(
ed.Id, ed.SchoolName, ed.Major, ed.DegreeLevel, ed.EducationMode, ed.GradeLevel,
ed.FromDate, ed.ToDate, ed.CertificateIssueDate, ed.Notes)).ToList(),
entity.FamilyRelations.Select(f => new EmployeeFamilyRelationDto(
f.Id, f.FullName, f.Relationship,
f.BirthYear, f.Occupation, f.CurrentAddress, f.Phone)).ToList(),
entity.Skills.Select(s => new EmployeeSkillDto(
s.Id, s.Kind, s.Name, s.LanguageId, s.Level)).ToList(),
entity.Documents.Select(doc => new EmployeeDocumentDto(
doc.Id, doc.DocumentType,
doc.FileName, doc.FilePath, doc.FileSize, doc.ContentType,
doc.IssueDate, doc.ExpiryDate, doc.Notes)).ToList());
}
}
// ========== LIST (paged) ==========
// PagedRequest provides Page/PageSize/Search/SortDesc (pattern PE List).
// Spec dùng `PagedQuery` nhưng codebase chỉ có `PagedRequest` — em main confirm
// dùng PagedRequest (Pattern 12-bis mirror PE ListPurchaseEvaluationsQuery line 458).
public record ListEmployeesQuery(
EmployeeStatus? Status = null,
Guid? DepartmentId = null) : PagedRequest, IRequest<PagedResult<EmployeeProfileListItemDto>>;
public class ListEmployeesQueryHandler(
IApplicationDbContext db) : IRequestHandler<ListEmployeesQuery, PagedResult<EmployeeProfileListItemDto>>
{
public async Task<PagedResult<EmployeeProfileListItemDto>> Handle(
ListEmployeesQuery request, CancellationToken ct)
{
// JOIN Users for FullName/Email + Departments for DepartmentName (LEFT join
// Departments — User.DepartmentId nullable). Mirror PE Plan AG4 JOIN pattern.
var q = from e in db.EmployeeProfiles.AsNoTracking().Where(x => !x.IsDeleted)
join u in db.Users.AsNoTracking() on e.UserId equals u.Id
join d in db.Departments.AsNoTracking() on u.DepartmentId equals d.Id into dj
from d in dj.DefaultIfEmpty()
select new { e, u, d };
if (request.Status is not null)
q = q.Where(x => x.e.EmployeeStatus == request.Status);
if (request.DepartmentId is not null)
q = q.Where(x => x.u.DepartmentId == request.DepartmentId);
if (!string.IsNullOrWhiteSpace(request.Search))
{
var s = request.Search.Trim();
q = q.Where(x =>
x.e.EmployeeCode.Contains(s) ||
x.u.FullName.Contains(s));
}
q = request.SortDesc
? q.OrderByDescending(x => x.e.CreatedAt)
: q.OrderBy(x => x.e.CreatedAt);
var total = await q.CountAsync(ct);
var items = await q
.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
.Select(x => new EmployeeProfileListItemDto(
x.e.Id,
x.e.EmployeeCode,
x.e.UserId,
x.u.FullName,
x.u.Email,
x.u.DepartmentId,
x.d != null ? x.d.Name : null,
x.e.EmployeeStatus,
x.e.Phone,
x.e.HireDate,
x.e.CreatedAt,
x.e.UpdatedAt))
.ToListAsync(ct);
return new PagedResult<EmployeeProfileListItemDto>(items, total, request.Page, request.PageSize);
}
}

View File

@ -76,6 +76,15 @@ public static class MenuKeys
public const string BudgetCreate = "Bg_Create";
public const string BudgetPending = "Bg_Pending";
// ============================================================
// Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33 2026-05-26).
// Port từ NamGroup CT_NHANSU 1675 NV. 1 root group + 1 leaf Hồ sơ NS.
// Phase 1 minimal scope — satellite CRUD endpoint defer Phase 1.5.
// Future Phase 10.4 add: Hrm_Dashboard (G-H3) + Hrm_Config* (G-H2).
// ============================================================
public const string Hrm = "Hrm"; // root group
public const string HrmHoSo = "Hrm_HoSo"; // Hồ sơ Nhân sự (list + detail + edit)
public static readonly string[] PurchaseEvaluationTypeCodes =
["DuyetNcc", "DuyetNccPhuongAn"];
@ -100,6 +109,7 @@ public static class MenuKeys
Contracts, Forms, Reports,
PurchaseEvaluations,
Budgets, BudgetList, BudgetCreate, BudgetPending,
Hrm, HrmHoSo, // Mig 34 — Phase 10.1
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
];

View File

@ -1479,6 +1479,10 @@ public static class DbInitializer
(MenuKeys.BudgetList, "Danh sách", MenuKeys.Budgets, 1, "List"),
(MenuKeys.BudgetCreate, "Thao tác", MenuKeys.Budgets, 2, "Plus"),
(MenuKeys.BudgetPending, "Duyệt", MenuKeys.Budgets, 3, "CheckCircle2"),
// Module Nhân sự (Phase 10.1 G-H1 — Mig 34 S33). 1 root + 1 leaf
// Phase 1 minimal. Phase 1.5 + G-H2/G-H3 thêm Config/Dashboard.
(MenuKeys.Hrm, "Nhân sự", null, 28, "UserCircle"),
(MenuKeys.HrmHoSo, "Hồ sơ Nhân sự", MenuKeys.Hrm, 1, "ContactRound"),
};
// Per-type sub-menu under Contracts: 1 group + 3 leaves each