Compare commits
3 Commits
48a99e14e7
...
79a8343de3
| Author | SHA1 | Date | |
|---|---|---|---|
| 79a8343de3 | |||
| 9616ae219c | |||
| 0e191deea5 |
@ -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]`.
|
||||
|
||||
@ -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]`.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
205
fe-admin/src/pages/hrm/EmployeeCreatePage.tsx
Normal file
205
fe-admin/src/pages/hrm/EmployeeCreatePage.tsx
Normal 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ồ sơ 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ồ sơ NV. Tạo user mới ở mục System > 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, ...) có thể bổ sung ở mục Sửa hồ sơ sau khi tạo.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
573
fe-admin/src/pages/hrm/EmployeesListPage.tsx
Normal file
573
fe-admin/src/pages/hrm/EmployeesListPage.tsx
Normal 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ồ sơ 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ồ sơ
|
||||
</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">Mã 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ồ sơ.</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ồ sơ
|
||||
</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">Lý 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">Mã: {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>
|
||||
}
|
||||
306
fe-admin/src/types/employee.ts
Normal file
306
fe-admin/src/types/employee.ts
Normal 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[]
|
||||
}
|
||||
@ -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="*"
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
205
fe-user/src/pages/hrm/EmployeeCreatePage.tsx
Normal file
205
fe-user/src/pages/hrm/EmployeeCreatePage.tsx
Normal 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ồ sơ 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ồ sơ NV. Tạo user mới ở mục System > 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, ...) có thể bổ sung ở mục Sửa hồ sơ sau khi tạo.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
573
fe-user/src/pages/hrm/EmployeesListPage.tsx
Normal file
573
fe-user/src/pages/hrm/EmployeesListPage.tsx
Normal 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ồ sơ 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ồ sơ
|
||||
</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">Mã 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ồ sơ.</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ồ sơ
|
||||
</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">Lý 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">Mã: {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>
|
||||
}
|
||||
306
fe-user/src/types/employee.ts
Normal file
306
fe-user/src/types/employee.ts
Normal 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[]
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
151
src/Backend/SolutionErp.Application/Hrm/Dtos/EmployeeDtos.cs
Normal file
151
src/Backend/SolutionErp.Application/Hrm/Dtos/EmployeeDtos.cs
Normal 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);
|
||||
610
src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs
Normal file
610
src/Backend/SolutionErp.Application/Hrm/EmployeeFeatures.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
];
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user