Compare commits

..

5 Commits

Author SHA1 Message Date
bf177408b0 [CLAUDE] Docs: chốt Session 11 — Migration 17 manual budget fields PE + HĐ
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m17s
Chunk 5/5 — close session log + STATUS Recently Done + HANDOFF TL;DR Session 11
+ migration-todos tick + 6 cảnh báo Session 12+. KHÔNG update skill (per §9.5
— defer cron audit 2026-06-01 nhỏ enough). KHÔNG update schema-diagram count
55→55 (không bảng mới, chỉ +4 column).

Files:
  ~ docs/STATUS.md       — Last updated S11 + Phase summary 16→17 mig + Recently Done row
  ~ docs/HANDOFF.md      — TL;DR Session 11 prepend + 6 cảnh báo + giữ S10 narrative
  + docs/changelog/sessions/2026-05-07-2300-pe-hd-manual-budget-mig17.md
  ~ docs/changelog/migration-todos.md — Session 11 done block

Validation per §6.5: KHÔNG cắt narrative, chỉ thêm rows + sections mới.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:43:00 +07:00
14f8d9d808 [CLAUDE] FE-User: PE + HĐ toggle "Nhập tay" + 2 fields manual budget mirror fe-admin
Chunk 4/5 — mirror y hệt Chunk 3 sang fe-user (rule §3.9 duplicate có chủ đích).

Files:
  ~ fe-user/src/types/purchaseEvaluation.ts — PeDetailBundle +2 field
  ~ fe-user/src/types/contracts.ts — ContractDetail +2 field
  ~ fe-user/src/components/pe/PeHeaderForm.tsx (copy từ fe-admin)
  ~ fe-user/src/components/pe/PeDetailTabs.tsx — Section "b. Ngân sách"
    fallback display khi !ev.budget + có manual data
  ~ fe-user/src/pages/pe/PurchaseEvaluationCreatePage.tsx (copy refactor wrap)
  ~ fe-user/src/pages/contracts/ContractCreatePage.tsx — toggle pattern cho
    NewContractForm + EditContractForm (giống fe-admin)

Verify: npm run build fe-user pass · 1904 modules · 0 TS error.

Next: Chunk 5 docs + push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:40:29 +07:00
bab503189a [CLAUDE] FE-Admin: PE + HĐ toggle "Nhập tay" + 2 fields manual budget (Mig 17)
Chunk 3/5 — UI cho manual budget fallback Mig 17. Toggle checkbox "Nhập tay
(không link)" cạnh Label Ngân sách. Khi ON: hide Select Budget, show 2 input
field grid 2-col (Tên tham chiếu text + Số tiền number formatted VND).

Files sửa:
  ~ fe-admin/src/types/purchaseEvaluation.ts — PeDetailBundle +2 field
  ~ fe-admin/src/types/contracts.ts — ContractDetail +2 field
  ~ fe-admin/src/components/pe/PeHeaderForm.tsx — toggle + 2 input + payload
    conditional (manual mode → clear budgetId, link mode → clear manual). Auto-
    detect manual mode khi load existing PE có manual data + !budgetId.
  ~ fe-admin/src/components/pe/PeDetailTabs.tsx — Section 2 "b. Ngân sách"
    fallback display khi !ev.budget + có manual data: render text "Tên · Số tiền"
    + badge "nhập tay" thay vì "(chưa link)".
  ~ fe-admin/src/pages/pe/PurchaseEvaluationCreatePage.tsx — refactor wrap
    PeHeaderForm để DRY (auto-inherit toggle pattern, không drift). 222 LOC → 30 LOC.
  ~ fe-admin/src/pages/contracts/ContractCreatePage.tsx — apply same toggle
    pattern cho cả NewContractForm + EditContractForm. EditForm thêm read-only
    display branch khi !isDraft + có manual data.

Verify: npm run build fe-admin pass · 1922 modules · không TS error.

Next: Chunk 4 fe-user mirror (PeHeaderForm + PeDetailTabs + ContractCreatePage
+ types).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:37:42 +07:00
0f7901c19f [CLAUDE] App: PE + Contract Create/Update commands + DTO add manual budget fields
Chunk 2/5 — wire 2 field mới (BudgetManualName + BudgetManualAmount) qua tất cả
CQRS commands + handlers + DTOs cho cả PE và HĐ. Mirror logic per Q3 user.

Files sửa:
  ~ Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs
    - CreatePurchaseEvaluationCommand record +2 param
    - Validator: MaximumLength(200) + GreaterThanOrEqualTo(0)
    - Handler: wire entity
    - UpdatePurchaseEvaluationDraftCommand record +2 param + handler wire
    - GetPurchaseEvaluationQuery → DTO mapping +2 field
  ~ Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs
    - PurchaseEvaluationDetailBundleDto +2 field (BudgetManualName/Amount)
  ~ Application/PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs
    - Carry forward pe.BudgetManualName/Amount → contract khi gen HĐ từ phiếu
  ~ Application/Contracts/ContractFeatures.cs
    - CreateContractCommand record +2 param + Validator + Handler
    - UpdateContractDraftCommand record +2 param + Handler (diff log thêm 2 field)
    - ContractDetailDto mapping +2 field
  ~ Application/Contracts/Dtos/ContractDtos.cs
    - ContractDetailDto +2 field

Validation Q2 chốt: cả 2 cùng null OK. Manual amount có thể null hoặc >= 0.
KHÔNG XOR với BudgetId (BE prefer link BudgetId nếu set, manual fallback only).

Controllers KHÔNG đụng (FromBody bind JSON → record record optional fields gen
auto null cho legacy callers — backward compat).

Verify: dotnet build pass · dotnet test SolutionErp.slnx 83 pass.

Next: Chunk 3 FE-Admin toggle + 2 fields PeHeaderForm + ContractHeaderForm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:34:00 +07:00
ecd5f7e9d9 [CLAUDE] Domain+Infra: Migration 17 — manual budget fields cho PE + HĐ
Chunk 1/5 — DB schema + Domain layer cho fallback "user nhập số tiền ngân sách
tay khi chưa link Budget entity" (UAT request 2026-05-07). Áp cho cả PE + HĐ
(mirror logic per user Q3 chốt).

Migration 17 `AddManualBudgetFieldsToPeAndContract` — 4 ALTER:
  - PurchaseEvaluations.BudgetManualName  nvarchar(200) NULL
  - PurchaseEvaluations.BudgetManualAmount decimal(18,2) NULL
  - Contracts.BudgetManualName            nvarchar(200) NULL
  - Contracts.BudgetManualAmount          decimal(18,2) NULL

Validation Q2: cả 2 cùng null OK (PE/HĐ chưa có ngân sách gì cả). KHÔNG XOR
với BudgetId — tạm thời cho phép cả 2 cùng có (BE prefer BudgetId nếu set vì
có Phase=DaDuyet guarantee, manual chỉ là fallback hiển thị/note).

Files:
  ~ Domain/PurchaseEvaluations/PurchaseEvaluation.cs — 2 property mới
  ~ Domain/Contracts/Contract.cs — 2 property mới
  ~ Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs
    — HasMaxLength(200) + HasPrecision(18,2)
  ~ Infrastructure/Persistence/Configurations/ContractConfiguration.cs — same
  + Migration 17 .cs + .Designer.cs (3-file rule per ef-core-migration skill)
  ~ ApplicationDbContextModelSnapshot.cs (auto-overwrite)

Verify:
  - dotnet ef migrations add → 3 file gen sạch (4 AddColumn Up + 4 DropColumn Down)
  - dotnet ef database update → applied LocalDB OK
  - dotnet test SolutionErp.slnx → 83 pass (54 Domain + 29 Infra) — không regression

Next: Chunk 2 App CQRS Create/Update commands + DTO + AutoMapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:30:59 +07:00
28 changed files with 4400 additions and 527 deletions

View File

@ -1,6 +1,28 @@
# HANDOFF — Brief 5 phút cho session tiếp theo # HANDOFF — Brief 5 phút cho session tiếp theo
**Last updated:** 2026-05-07 (Session 10 chốt — **PE "Thao tác" 2-panel workspace + Section 5 disabled. 2 commit per-chunk pushed. 83 test pass. Phase 9 còn lại = Hard blockers chờ user.**) **Last updated:** 2026-05-07 (Session 11 chốt — **Migration 17 manual budget fields PE + HĐ + toggle "Nhập tay". 5 commit per-chunk. 83 test pass.**)
## TL;DR Session 11 (07/05 — Migration 17 manual budget fields)
**Output session 11** — UX improvement cho user nhập ngân sách không cần Budget entity approved:
-**Migration 17** `AddManualBudgetFieldsToPeAndContract` — 4 ALTER (PE + HĐ × BudgetManualName nvarchar(200) + BudgetManualAmount decimal(18,2)). Applied LocalDB OK. Mirror logic PE ↔ HĐ (Q3 user chốt).
-**BE** Domain 2 entity (PE + Contract) +2 property + EF config HasMaxLength/HasPrecision. App CQRS Create/Update commands + DTO + Validator + diff log + carry-forward CreateContractFromEvaluation pe→contract.
-**FE** Toggle checkbox "Nhập tay (không link)" cạnh Label Ngân sách trong 4 chỗ: PeHeaderForm (workspace + /new page wrap), ContractCreatePage NewForm + EditForm. Khi ON: hide Select, show 2 input field grid 2-col (Tên + Số tiền formatted VND). Khi OFF (default): Select Budget approved cũ. Auto-detect manual mode khi load existing có manual data + !budgetId. Display fallback ở PeDetailTabs Section 1 "b. Ngân sách" + Contract EditForm read-only branch.
-**5 chunk per-commit** (build + 83 test pass mỗi chunk): C1 Domain+Infra Migration 17 (`ecd5f7e`) · C2 App CQRS (`0f7901c`) · C3 FE-Admin (`bab5031`) · C4 FE-User mirror (`14f8d9d`) · C5 Docs (current).
**Validation Q2 chốt:** cả BudgetId + manual fields có thể cùng null (PE/HĐ chưa có ngân sách gì). KHÔNG XOR enforce — BE prefer link nếu có (Phase=DaDuyet guarantee), manual fallback only.
**KHÔNG đụng:** Budget entity / Phase=DaDuyet validation (giữ invariant "PE/HĐ link Budget approved only"). Manual fields chỉ là note/display, KHÔNG join với Budget.Details cho per-row comparison ở PE matrix Section 4 (cột "So với ngân sách" vẫn require ev.budgetId — không có detail rows để compare).
## ⚠️ CẢNH BÁO session tiếp (Session 12+)
1. **UAT manual budget flow** — toggle ON/OFF + save + reload + verify auto-detect mode. Đặc biệt edit existing PE/HĐ có manual data → toggle phải auto-checked. Edit existing có Budget link → toggle auto-unchecked.
2. **Section 4 PE matrix "So với ngân sách"** vẫn require Budget link — manual amount KHÔNG render comparison column. Document giới hạn này nếu UAT thắc mắc.
3. **`docs/database/schema-diagram.md`** chưa cập nhật count 16→17 migration + 4 column mới. Defer cho audit định kỳ 2026-06-01 (per §6.4 cron) — nhỏ enough không phải selective rewrite.
4. **CreateContractFromEvaluation** đã carry forward manual fields PE→Contract. Verify khi UAT: PE có manual budget → tạo HĐ từ phiếu → HĐ inherit luôn.
5. **No new test added** (rule §7 — feature mới = test-after, soak UAT 2-3 tuần ổn → viết happy path).
6. **Schema fields nullable** — không phá HĐ/PE cũ (legacy null OK). Backward compatible.
## TL;DR Session 10 (07/05 — PE workspace 2-panel) ## TL;DR Session 10 (07/05 — PE workspace 2-panel)

View File

@ -2,9 +2,9 @@
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`. > **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
**Last updated:** 2026-05-07 (Session 10 chốt — **PE Thao tác 2-panel workspace + Section 5 disabled + 2 commit per-chunk pushed. 83 test pass.**) **Last updated:** 2026-05-07 (Session 11 chốt — **Migration 17 manual budget fields PE + HĐ. 5 commit per-chunk. 83 test pass.**)
## 📍 Phase hiện tại: **Phase 9 active — UAT** — **55 DB tables, 16 migrations, ~133 API endpoints, 32 FE pages (+1 PE workspace 2-panel). 83 unit test pass** (54 Domain + 29 Infra: 17 codegen + 6 PE WF + 6 2-stage). 41 gotcha. 30 demo user. 6 skill. **7 file User Manual** (~86 KB compact end-user style). ## 📍 Phase hiện tại: **Phase 9 active — UAT** — **55 DB tables, 17 migrations, ~133 API endpoints, 32 FE pages. 83 unit test pass** (54 Domain + 29 Infra). 41 gotcha. 30 demo user. 6 skill.
### 🌐 Production URLs ### 🌐 Production URLs
@ -61,6 +61,7 @@
| Ngày | Ai | Task | Commit | | Ngày | Ai | Task | Commit |
|---|---|---|---| |---|---|---|---|
| 2026-05-07 | Claude | **Migration 17 — manual budget fields fallback cho PE + HĐ (toggle "Nhập tay")** — User feedback: khi project chưa có Budget approved (Phase=DaDuyet eligible), user phải break flow đi tạo Budget + duyệt + quay lại link. UX kém. Solution: thêm fallback "Nhập tay" — checkbox toggle cạnh Label Ngân sách, khi ON → hide Select Budget, show 2 input field grid 2-col (Tên tham chiếu text + Số tiền number formatted VND). Lưu trên entity row, KHÔNG cần Budget entity. Q1-3 chốt: 1 = stick-toggle reveal 2 input fields; 2 = cả BudgetId + manual fields cùng null OK (KHÔNG XOR validate); 3 = mirror logic sang HĐ luôn (cả 7 ContractType qua ContractCreatePage). 5 chunk per-commit (build + 83 test pass mỗi chunk): C1 Migration 17 `AddManualBudgetFieldsToPeAndContract` 4 ALTER (PE + HĐ × Name nvarchar(200) + Amount decimal(18,2)) + Domain 2 entity + 2 EF config (HasMaxLength + HasPrecision) — applied LocalDB OK, 3-file rule. C2 App CQRS — CreatePurchaseEvaluationCommand + Update + DTO + Validator (>=0 when has value), CreateContractCommand + Update + DTO + diff log audit, CreateContractFromEvaluation carry forward pe.BudgetManualName/Amount → contract khi gen HĐ từ phiếu. C3 FE-Admin — types +2 field, PeHeaderForm toggle + 2 input + payload conditional (manual mode clear budgetId, link mode clear manual), PeDetailTabs Section "b. Ngân sách" fallback display "Tên · Số tiền + badge nhập tay" khi !budget + có manual data, refactor PurchaseEvaluationCreatePage wrap PeHeaderForm DRY (222→30 LOC), ContractCreatePage NewContractForm + EditContractForm cùng pattern + read-only display branch khi !isDraft. C4 fe-user mirror y hệt 6 file. C5 docs (this row + HANDOFF + session log). KHÔNG đụng Budget entity / Phase=DaDuyet validation (giữ invariant). | `ecd5f7e` (C1) · `0f7901c` (C2) · `bab5031` (C3) · `14f8d9d` (C4) · (current C5) |
| 2026-05-07 | Claude | **PE "Thao tác" 2-panel workspace + Panel 1 read-only picker + Section 5 disabled** — User chỉ thị restructure leaf "Thao tác" (Pe_DuyetNcc_Create + Pe_DuyetNccPhuongAn_Create) từ page tạo header riêng (`/purchase-evaluations/new?type=N` — chỉ form Tên/Project/Địa điểm/Payment/Budget) sang workspace 2-panel mirror pattern HĐ Thầu phụ ContractCreatePage. 5 câu chốt spec trước code: Q1 Panel 2 KHÔNG render Workflow Panel + Approvals + History (chỉ data entry); Panel 1 = pure picker, KHÔNG inline edit/delete; Q2 mirror HĐ Thầu phụ pattern (sticky "+ Thêm mới" + Panel 2 transition new→edit form); Q3 leaf "Danh sách" + "Duyệt" giữ 3-panel hiện tại; Q4 route mới `/purchase-evaluations/workspace?type={1\|2}`; Q5 Section 5 Ý kiến 4PB disable trong workspace (vì người ta nhập khi duyệt, không phải lúc nhập liệu). 2 chunk per-commit (build + 83 test pass mỗi chunk): C1 fe-admin (3 file mới `PeListPanel.tsx` ~180 LOC pure picker reuse + `PeHeaderForm.tsx` ~210 LOC extract + `PurchaseEvaluationWorkspacePage.tsx` ~120 LOC, 3 file sửa `PeDetailTabs.tsx` thêm prop `mode?: 'detail' \| 'workspace'` + Section 5 hint banner amber + Layout.tsx resolver `Pe_*_Create``/workspace?type=N` + App.tsx route mới). C2 fe-user mirror y hệt 6 file (rule §3.9). KHÔNG đụng BE / migration / schema / endpoint. Route `/new` cũ giữ tồn tại cho deep-link "Sửa header" button. **Total +1142 LOC FE / 0 BE / 32 FE pages.** | `ee0d360` (C1) · `ecf3c59` (C2) · (current C3) | | 2026-05-07 | Claude | **PE "Thao tác" 2-panel workspace + Panel 1 read-only picker + Section 5 disabled** — User chỉ thị restructure leaf "Thao tác" (Pe_DuyetNcc_Create + Pe_DuyetNccPhuongAn_Create) từ page tạo header riêng (`/purchase-evaluations/new?type=N` — chỉ form Tên/Project/Địa điểm/Payment/Budget) sang workspace 2-panel mirror pattern HĐ Thầu phụ ContractCreatePage. 5 câu chốt spec trước code: Q1 Panel 2 KHÔNG render Workflow Panel + Approvals + History (chỉ data entry); Panel 1 = pure picker, KHÔNG inline edit/delete; Q2 mirror HĐ Thầu phụ pattern (sticky "+ Thêm mới" + Panel 2 transition new→edit form); Q3 leaf "Danh sách" + "Duyệt" giữ 3-panel hiện tại; Q4 route mới `/purchase-evaluations/workspace?type={1\|2}`; Q5 Section 5 Ý kiến 4PB disable trong workspace (vì người ta nhập khi duyệt, không phải lúc nhập liệu). 2 chunk per-commit (build + 83 test pass mỗi chunk): C1 fe-admin (3 file mới `PeListPanel.tsx` ~180 LOC pure picker reuse + `PeHeaderForm.tsx` ~210 LOC extract + `PurchaseEvaluationWorkspacePage.tsx` ~120 LOC, 3 file sửa `PeDetailTabs.tsx` thêm prop `mode?: 'detail' \| 'workspace'` + Section 5 hint banner amber + Layout.tsx resolver `Pe_*_Create``/workspace?type=N` + App.tsx route mới). C2 fe-user mirror y hệt 6 file (rule §3.9). KHÔNG đụng BE / migration / schema / endpoint. Route `/new` cũ giữ tồn tại cho deep-link "Sửa header" button. **Total +1142 LOC FE / 0 BE / 32 FE pages.** | `ee0d360` (C1) · `ecf3c59` (C2) · (current C3) |
| 2026-05-04 | Claude | **User Manual 7 file rewrite compact cho end-user** — User feedback "ko cần quá đầy đủ chi tiết, cho end-user họ làm". Setup `package.json` + `npm install docx@9.5.0` + script `npm run gen:all`. Rewrite 7 generator scripts theo style end-user friendly: BỎ field validation table 5 cột (Tên field/Kiểu/Bắt buộc/Validation/Ví dụ), BỎ error troubleshoot table 3 cột (Lỗi/Nguyên nhân/Cách xử lý), BỎ FAQ chi tiết 8 câu (giữ 1 chương "Khi gặp lỗi" 4-5 bullet), BỎ phím tắt table. GIỮ: tổng quan ngắn 1-2 câu mỗi chức năng, numbered steps đơn giản 3-7 bước, note/warn/tip chỉ critical, bullet liệt kê. 7 file: 01-Bat-dau (12.1KB cũ 21.7KB ↓44%) / 02-Hop-dong / 03-Duyet-Workflow (mention 2-stage NV/TPB Mig 16) / 04-PE-Phieu-Duyet-NCC (A/B + 4PB + tạo HĐ) / 05-Budget / 06-7-Loai-HD-Cheatsheet / admin-02-Quan-ly-Users-Roles (mention bypass review S9). Refactor user-01 dùng `_helpers.js` shared (trước có helpers inline 793 dòng, giờ ~110 dòng). Tổng output ~86KB / 7 file (cũ ~123KB ↓30%). | `16c2c9c` | | 2026-05-04 | Claude | **User Manual 7 file rewrite compact cho end-user** — User feedback "ko cần quá đầy đủ chi tiết, cho end-user họ làm". Setup `package.json` + `npm install docx@9.5.0` + script `npm run gen:all`. Rewrite 7 generator scripts theo style end-user friendly: BỎ field validation table 5 cột (Tên field/Kiểu/Bắt buộc/Validation/Ví dụ), BỎ error troubleshoot table 3 cột (Lỗi/Nguyên nhân/Cách xử lý), BỎ FAQ chi tiết 8 câu (giữ 1 chương "Khi gặp lỗi" 4-5 bullet), BỎ phím tắt table. GIỮ: tổng quan ngắn 1-2 câu mỗi chức năng, numbered steps đơn giản 3-7 bước, note/warn/tip chỉ critical, bullet liệt kê. 7 file: 01-Bat-dau (12.1KB cũ 21.7KB ↓44%) / 02-Hop-dong / 03-Duyet-Workflow (mention 2-stage NV/TPB Mig 16) / 04-PE-Phieu-Duyet-NCC (A/B + 4PB + tạo HĐ) / 05-Budget / 06-7-Loai-HD-Cheatsheet / admin-02-Quan-ly-Users-Roles (mention bypass review S9). Refactor user-01 dùng `_helpers.js` shared (trước có helpers inline 793 dòng, giờ ~110 dòng). Tổng output ~86KB / 7 file (cũ ~123KB ↓30%). | `16c2c9c` |
| 2026-05-04 | Claude | **Optional polish — fe-user Inbox thêm section "Phiếu Duyệt NCC chờ tôi"** — User chỉ thị "tiếp tục plan tổng" → pick task không blocked. useQuery thứ 2 cho `/purchase-evaluations/inbox` (endpoint có sẵn), peRows filter theo search. Stats overdue/dueSoon đếm cả PE rows (totalValue chỉ HĐ vì PE không có giá trị). Panel 1 chia 2 section sticky header: "Hợp đồng (N)" giữ behavior cũ click → inline detail Panel 2; "Phiếu Duyệt NCC (M)" click → navigate `/purchase-evaluations/:id` page riêng (PE entity shape khác Contract, không inline). EmptyState mới: "Không có HĐ hoặc Phiếu Duyệt NCC nào chờ". Chỉ fe-user (Drafter + TPB dùng Inbox) — fe-admin defer. Build pass. | `332a90f` | | 2026-05-04 | Claude | **Optional polish — fe-user Inbox thêm section "Phiếu Duyệt NCC chờ tôi"** — User chỉ thị "tiếp tục plan tổng" → pick task không blocked. useQuery thứ 2 cho `/purchase-evaluations/inbox` (endpoint có sẵn), peRows filter theo search. Stats overdue/dueSoon đếm cả PE rows (totalValue chỉ HĐ vì PE không có giá trị). Panel 1 chia 2 section sticky header: "Hợp đồng (N)" giữ behavior cũ click → inline detail Panel 2; "Phiếu Duyệt NCC (M)" click → navigate `/purchase-evaluations/:id` page riêng (PE entity shape khác Contract, không inline). EmptyState mới: "Không có HĐ hoặc Phiếu Duyệt NCC nào chờ". Chỉ fe-user (Drafter + TPB dùng Inbox) — fe-admin defer. Build pass. | `332a90f` |

View File

@ -219,6 +219,18 @@ Session log: `2026-05-04-1230-chot-session-8-2-stage-dept-approval.md`.
- [x] **Optional polish — fe-user Inbox PE section** (commit `332a90f`). HĐ + PE 2 section trong InboxPage. - [x] **Optional polish — fe-user Inbox PE section** (commit `332a90f`). HĐ + PE 2 section trong InboxPage.
- [x] **User Manual 7 file rewrite compact** (commit `16c2c9c`). End-user style: bỏ field/error tables, giữ numbered steps đơn giản. ~86 KB total. - [x] **User Manual 7 file rewrite compact** (commit `16c2c9c`). End-user style: bỏ field/error tables, giữ numbered steps đơn giản. ~86 KB total.
### ✅ Session 11 done (2026-05-07) — Migration 17 manual budget fields PE + HĐ
User feedback: PE/HĐ link Budget Select chỉ Phase=DaDuyet → user phải break flow tạo Budget approved trước. Solution: toggle "Nhập tay" + 2 input field fallback (Tên text + Số tiền number) lưu trên entity, KHÔNG cần Budget entity. Mirror logic PE ↔ HĐ (Q3 chốt).
- [x] **Chunk 1 Domain+Infra** (commit `ecd5f7e`) — Migration 17 `AddManualBudgetFieldsToPeAndContract` 4 ALTER + 2 entity property + 2 EF config (HasMaxLength + HasPrecision). Applied LocalDB. 3-file rule.
- [x] **Chunk 2 App CQRS** (commit `0f7901c`) — Create/Update PE + Contract commands + Validator (>=0 when has value) + Handlers + DTO + diff log audit + CreateContractFromEvaluation carry forward.
- [x] **Chunk 3 FE-Admin** (commit `bab5031`) — types +2 field, PeHeaderForm toggle + 2 input + payload conditional, PeDetailTabs Section "b. Ngân sách" fallback display + badge "nhập tay", refactor PurchaseEvaluationCreatePage wrap PeHeaderForm DRY (222→30 LOC), ContractCreatePage NewForm + EditForm cùng pattern + read-only branch khi !isDraft.
- [x] **Chunk 4 FE-User mirror** (commit `14f8d9d`) — 6 file y hệt content (rule §3.9).
- [x] **Chunk 5 Docs** (commit current) — STATUS row + HANDOFF TL;DR Session 11 + session log `2026-05-07-2300-pe-hd-manual-budget-mig17.md`.
- [x] **Verify**: dotnet build + 83 test pass mỗi chunk · npm build fe-admin + fe-user pass · LocalDB migration applied.
- [x] **KHÔNG đụng** Budget entity / Phase=DaDuyet validation (giữ invariant). Manual fields chỉ là fallback display/note, KHÔNG join với Budget.Details cho per-row comparison ở PE matrix Section 4.
### ✅ Session 10 done (2026-05-07) — PE "Thao tác" 2-panel workspace ### ✅ Session 10 done (2026-05-07) — PE "Thao tác" 2-panel workspace
User chỉ thị restructure menu PE: leaf "Thao tác" (Pe_*_Create) từ page Create header riêng `/new` sang workspace 2-panel mirror pattern HĐ Thầu phụ ContractCreatePage. Spec chốt 5 câu trước code (xem session log đầy đủ rationale). User chỉ thị restructure menu PE: leaf "Thao tác" (Pe_*_Create) từ page Create header riêng `/new` sang workspace 2-panel mirror pattern HĐ Thầu phụ ContractCreatePage. Spec chốt 5 câu trước code (xem session log đầy đủ rationale).

View File

@ -0,0 +1,116 @@
# Session 2026-05-07 23:00 — Migration 17 manual budget fields PE + HĐ
**Dev:** Claude
**Duration:** ~1.5h
**Base commit:** `d04bd88` (sau Session 10 PE workspace)
## Bối cảnh
User feedback: trong PE workspace + HĐ Create form, link Budget Select chỉ list ngân sách Phase=DaDuyet. Khi project chưa có Budget approved, user phải break flow đi tạo Budget → trình duyệt → quay lại link. UX đứt mạch.
User chốt: thêm fallback "user tự nhập tiền vào, ko cần link ngân sách". Toggle reveal 2 input field (text Tên + number Số tiền). Mirror logic sang HĐ luôn (Q3).
3 câu chốt spec:
- **Q1** Toggle cho hiện 2 input number/text field. Confirm "stick = toggle, xong cho input vào 2 field".
- **Q2** Cả 2 cùng null OK (BudgetId + manual fields). KHÔNG XOR validate — tạm thời cho phép cả 2 cùng có (BE prefer BudgetId nếu set vì Phase=DaDuyet guarantee, manual chỉ fallback).
- **Q3** Mirror sang HĐ Thầu phụ (cả 7 ContractType qua ContractCreatePage chung).
## Làm được
### Chunk 1 — Domain + Infra Migration 17 (commit `ecd5f7e`, +3674 LOC, 7 files)
**Migration 17** `AddManualBudgetFieldsToPeAndContract` — 4 ALTER:
- `PurchaseEvaluations.BudgetManualName` nvarchar(200) NULL
- `PurchaseEvaluations.BudgetManualAmount` decimal(18,2) NULL
- `Contracts.BudgetManualName` nvarchar(200) NULL
- `Contracts.BudgetManualAmount` decimal(18,2) NULL
3-file rule per `ef-core-migration` skill: `.cs` + `.Designer.cs` + ApplicationDbContextModelSnapshot.cs (auto-overwrite).
**Files:**
- `Domain/PurchaseEvaluations/PurchaseEvaluation.cs` — 2 property mới (cmt rationale Mig 17 + Q2 validation note)
- `Domain/Contracts/Contract.cs` — 2 property mới
- `Infrastructure/Persistence/Configurations/PurchaseEvaluationConfiguration.cs` — HasMaxLength(200) + HasPrecision(18, 2)
- `Infrastructure/Persistence/Configurations/ContractConfiguration.cs` — same
- Migration 17 (3 file)
**Verify:** `dotnet ef migrations add` clean output (4 AddColumn Up + 4 DropColumn Down). `dotnet ef database update` applied LocalDB. `dotnet test SolutionErp.slnx` 83 pass.
### Chunk 2 — App CQRS (commit `0f7901c`, +36/-4 LOC, 5 files)
- `CreatePurchaseEvaluationCommand` + Validator (MaximumLength(200) + GreaterThanOrEqualTo(0) when has value) + Handler wire entity
- `UpdatePurchaseEvaluationDraftCommand` + Handler wire entity
- `PurchaseEvaluationDetailBundleDto` +2 field
- `GetPurchaseEvaluationQuery` handler → DTO mapping pass entity values
- Mirror `CreateContractCommand` + `UpdateContractDraftCommand` + Validator + diff log audit thêm 2 field
- `ContractDetailDto` +2 field + GetContract handler mapping
- `CreateContractFromEvaluationFeatures.cs` — carry forward `pe.BudgetManualName/Amount``contract` khi gen HĐ từ phiếu (nếu PE đã có manual data, HĐ inherit luôn — không lose data khi gen HĐ).
**Controllers KHÔNG đụng**`[FromBody]` bind JSON body → record record optional fields gen auto null cho legacy callers. Backward compat.
### Chunk 3 — FE-Admin (commit `bab5031`, +268/-260 LOC, 6 files)
- `fe-admin/src/types/purchaseEvaluation.ts` PeDetailBundle +2 field
- `fe-admin/src/types/contracts.ts` ContractDetail +2 field
- `fe-admin/src/components/pe/PeHeaderForm.tsx` — toggle checkbox "Nhập tay (không link)" cạnh Label. ON: hide Select, show 2 input grid 2-col (Tên + Số tiền number formatted "≈ X đ" preview). OFF: Select Budget approved cũ. Payload conditional clear opposite mode. Auto-detect mode khi load existing.
- `fe-admin/src/components/pe/PeDetailTabs.tsx` — Section 2 "b. Ngân sách" fallback display khi !ev.budget + có manual data: render "Tên · Số tiền + badge nhập tay" thay vì "(chưa link)".
- `fe-admin/src/pages/pe/PurchaseEvaluationCreatePage.tsx` — refactor wrap PeHeaderForm DRY (222 LOC → 30 LOC). Auto-inherit toggle pattern, không drift.
- `fe-admin/src/pages/contracts/ContractCreatePage.tsx` — apply same toggle pattern cho NewContractForm + EditContractForm. EditForm thêm read-only display branch khi !isDraft + có manual data.
### Chunk 4 — FE-User mirror (commit `14f8d9d`, +268/-260 LOC, 6 files)
Y hệt Chunk 3 (rule §3.9). Copy 3 file mới + 3 file edit identical.
### Chunk 5 — Docs (commit current)
- STATUS.md Last updated + Phase summary count 16→17 migration + Recently Done row chi tiết.
- HANDOFF.md TL;DR Session 11 prepend + 6 cảnh báo Session 12+ + giữ Session 10 narrative.
- Session log (file này).
- KHÔNG update skill `ef-core-migration` table count — defer cron audit 2026-06-01 (small, không drift major).
## E2E verified
- `dotnet build` solution pass · `dotnet test SolutionErp.slnx` 83 pass · `dotnet ef database update` applied LocalDB.
- `npm run build` fe-admin pass — 1922 modules.
- `npm run build` fe-user pass — 1904 modules.
- Manual smoke: pending UAT user thử các flow sau:
- PE workspace tạo mới → toggle "Nhập tay" ON → nhập "Tạm tính T11/2025" + "1000000000" → save → Panel 2 detail tabs → Section 1 b. Ngân sách hiển thị "Tạm tính T11/2025 · 1.000.000.000 đ + badge nhập tay".
- Edit PE đã có Budget link → toggle off (auto-detected) → unlink → toggle on → nhập manual → save → reload → toggle vẫn ON.
- HĐ Thầu phụ /contracts/new → toggle "Nhập tay" trong NewContractForm → save → /contracts/:id → EditForm vẫn toggle ON, hiện 2 field giá trị cũ.
- HĐ DangSoanThao → DangGopY → reload → !isDraft branch hiển thị "Tên · Số tiền + badge nhập tay" read-only.
- PE DaDuyet → tạo HĐ từ phiếu → HĐ inherit manual fields (CreateContractFromEvaluation carry forward).
## Bug gặp + fix
| Bug | Fix |
|---|---|
| fe-user ContractCreatePage NewContractForm Edit thứ nhất (state declaration) lần đầu fail "File has not been read yet" do Edit chưa Read fe-user contracts.ts trước. Sau đó subsequent Edits đến mid-form fail TS2304 "Cannot find name 'budgetManual'" | Re-read file lần thứ 2 + Edit lại state declaration. Build pass sau khi state có đủ. |
## Docs updates
- STATUS.md (1 row Recently Done + count 17 migration)
- HANDOFF.md (TL;DR Session 11 prepend + cảnh báo)
- session log (file này)
- KHÔNG update skill (per §9.5 — không drift đáng audit, defer cho cron 2026-06-01)
- KHÔNG update gotchas (không phát sinh bẫy mới đáng cluster)
- Schema diagram count 55→55 (không bảng mới, chỉ 4 column mới) — defer audit định kỳ
## Handoff
Phase 9 active. Hard blockers user/ops vẫn pending. Manual budget feature mở ra "free pass" cho user tạo phiếu/HĐ nhanh không cần Budget workflow — ROI lớn cho UAT mà không phá invariant.
Cron audit kế: 2026-06-01 (~25 ngày).
## Thông số cumulative (sau Session 11)
| | Trước S11 | Sau S11 |
|---|---:|---:|
| BE LOC | ~14400 | ~14450 (+50 — 2 entity property + 2 EF config + 4 command field + DTO field + carry forward) |
| API endpoints | ~133 | ~133 (no change) |
| Migrations | 16 | **17** (+1 `AddManualBudgetFieldsToPeAndContract`) |
| DB columns mới | 0 | +4 (PE + HĐ × Name + Amount) |
| FE pages | 32 | 32 (no change) |
| FE components | (existing) | (existing — refactor PeHeaderForm reuse + PurchaseEvaluationCreatePage wrap) |
| Tests | 83 | 83 (no change — feature mới = test-after rule §7) |
| Docs | ~53 | ~54 (+session log này) |
| Commits | (after S10) | +5 (C1 + C2 + C3 + C4 + C5) |

View File

@ -343,6 +343,17 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
{' · '}{ev.budget.tenNganSach} {' · '}{ev.budget.tenNganSach}
{' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span> {' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a> </a>
) : ev.budgetManualAmount != null || ev.budgetManualName ? (
// Mig 17 — manual budget fallback: hiển thị tên + số tiền nhập tay,
// không phải link vào /budgets/{id} (không có Budget entity).
<span className="text-slate-700">
{ev.budgetManualName && <span>{ev.budgetManualName}</span>}
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '}
{ev.budgetManualAmount != null && (
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
)}
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
</span>
) : <span className="text-slate-400"> (chưa link)</span>} ) : <span className="text-slate-400"> (chưa link)</span>}
/> />
<FormRow <FormRow

View File

@ -53,6 +53,10 @@ export function PeHeaderForm({
moTa: '', moTa: '',
paymentTerms: '', paymentTerms: '',
budgetId: '' as string, budgetId: '' as string,
// Mig 17 — manual budget fallback (toggle "Nhập tay" thay vì link)
budgetManual: false,
budgetManualName: '',
budgetManualAmount: 0,
}) })
const eligibleBudgets = useQuery({ const eligibleBudgets = useQuery({
@ -68,6 +72,8 @@ export function PeHeaderForm({
useEffect(() => { useEffect(() => {
if (existing.data) { if (existing.data) {
const hasManual = existing.data.budgetManualName !== null
|| existing.data.budgetManualAmount !== null
setForm({ setForm({
type: existing.data.type, type: existing.data.type,
tenGoiThau: existing.data.tenGoiThau, tenGoiThau: existing.data.tenGoiThau,
@ -76,10 +82,27 @@ export function PeHeaderForm({
moTa: existing.data.moTa ?? '', moTa: existing.data.moTa ?? '',
paymentTerms: existing.data.paymentTerms ?? '', paymentTerms: existing.data.paymentTerms ?? '',
budgetId: existing.data.budgetId ?? '', budgetId: existing.data.budgetId ?? '',
// Auto-toggle manual mode khi load existing có manual data hoặc không có link
budgetManual: hasManual && !existing.data.budgetId,
budgetManualName: existing.data.budgetManualName ?? '',
budgetManualAmount: existing.data.budgetManualAmount ?? 0,
}) })
} }
}, [existing.data]) }, [existing.data])
// Manual mode: clear budgetId, gửi manualName/Amount. Link mode: clear manual.
const payloadBudgetFields = form.budgetManual
? {
budgetId: null,
budgetManualName: form.budgetManualName || null,
budgetManualAmount: form.budgetManualAmount > 0 ? form.budgetManualAmount : null,
}
: {
budgetId: form.budgetId || null,
budgetManualName: null,
budgetManualAmount: null,
}
const mut = useMutation({ const mut = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (editId) { if (editId) {
@ -89,7 +112,7 @@ export function PeHeaderForm({
diaDiem: form.diaDiem || null, diaDiem: form.diaDiem || null,
moTa: form.moTa || null, moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null, paymentTerms: form.paymentTerms || null,
budgetId: form.budgetId || null, ...payloadBudgetFields,
}) })
} }
return api.post<{ id: string }>('/purchase-evaluations', { return api.post<{ id: string }>('/purchase-evaluations', {
@ -99,7 +122,7 @@ export function PeHeaderForm({
diaDiem: form.diaDiem || null, diaDiem: form.diaDiem || null,
moTa: form.moTa || null, moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null, paymentTerms: form.paymentTerms || null,
budgetId: form.budgetId || null, ...payloadBudgetFields,
}) })
}, },
onSuccess: res => { onSuccess: res => {
@ -161,26 +184,69 @@ export function PeHeaderForm({
</div> </div>
<div> <div>
<Label>Ngân sách (đi chiếu chi phí)</Label> <div className="mb-1.5 flex items-center justify-between">
<Select <Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label>
value={form.budgetId} {/* Toggle "Nhập tay" — Mig 17 fallback khi không link Budget entity */}
disabled={!form.projectId} <label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
onChange={e => setForm({ ...form, budgetId: e.target.value })} <input
> type="checkbox"
<option value=""> (không link)</option> checked={form.budgetManual}
{eligibleBudgets.data?.map(b => ( onChange={e => setForm({ ...form, budgetManual: e.target.checked })}
<option key={b.id} value={b.id}> className="h-3.5 w-3.5 rounded border-slate-300"
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ />
</option> Nhập tay (không link)
))} </label>
</Select> </div>
<p className="mt-1 text-[11px] text-slate-500"> {!form.budgetManual ? (
{!form.projectId <>
? 'Chọn dự án trước để xem ngân sách khả dụng.' <Select
: eligibleBudgets.data && eligibleBudgets.data.length === 0 value={form.budgetId}
? 'Dự án này chưa có ngân sách đã duyệt.' disabled={!form.projectId}
: 'Chỉ list ngân sách đã duyệt cùng dự án.'} onChange={e => setForm({ ...form, budgetId: e.target.value })}
</p> >
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="mt-1 text-[11px] text-slate-500">
{!form.projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<Input
value={form.budgetManualName}
onChange={e => setForm({ ...form, budgetManualName: e.target.value })}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/>
</div>
<div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={form.budgetManualAmount || ''}
onChange={e => setForm({ ...form, budgetManualAmount: Number(e.target.value) })}
placeholder="1000000000"
/>
{form.budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{form.budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div>
)}
</div> </div>
<div> <div>

View File

@ -305,6 +305,10 @@ function ContractHeaderForm({
const [noiDung, setNoiDung] = useState('') const [noiDung, setNoiDung] = useState('')
const [bypass, setBypass] = useState(false) const [bypass, setBypass] = useState(false)
const [budgetId, setBudgetId] = useState('') const [budgetId, setBudgetId] = useState('')
// Mig 17 — manual budget fallback (toggle "Nhập tay")
const [budgetManual, setBudgetManual] = useState(false)
const [budgetManualName, setBudgetManualName] = useState('')
const [budgetManualAmount, setBudgetManualAmount] = useState(0)
// Reset type về default khi typeFilter (parent prop) thay đổi // Reset type về default khi typeFilter (parent prop) thay đổi
useEffect(() => { setType(defaultType) }, [defaultType]) useEffect(() => { setType(defaultType) }, [defaultType])
@ -334,6 +338,11 @@ function ContractHeaderForm({
}) })
const qc = useQueryClient() const qc = useQueryClient()
// Manual mode: clear budgetId, gửi manualName/Amount. Link mode: clear manual.
const budgetPayload = budgetManual
? { budgetId: null, budgetManualName: budgetManualName || null, budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null }
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
const res = await api.post<{ id: string }>('/contracts', { const res = await api.post<{ id: string }>('/contracts', {
@ -347,7 +356,7 @@ function ContractHeaderForm({
noiDung: noiDung || null, noiDung: noiDung || null,
bypassProcurementAndCCM: bypass, bypassProcurementAndCCM: bypass,
draftData: null, draftData: null,
budgetId: budgetId || null, ...budgetPayload,
}) })
return res.data.id return res.data.id
}, },
@ -387,26 +396,69 @@ function ContractHeaderForm({
typeReadonly={false} typeReadonly={false}
/> />
<div className="mt-4 space-y-1.5"> <div className="mt-4 space-y-1.5">
<Label>Ngân sách (đi chiếu chi phí)</Label> <div className="flex items-center justify-between">
<Select <Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label>
value={budgetId} {/* Toggle "Nhập tay" — Mig 17 fallback khi không link Budget entity */}
disabled={!projectId} <label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
onChange={e => setBudgetId(e.target.value)} <input
> type="checkbox"
<option value=""> (không link)</option> checked={budgetManual}
{eligibleBudgets.data?.map(b => ( onChange={e => setBudgetManual(e.target.checked)}
<option key={b.id} value={b.id}> className="h-3.5 w-3.5 rounded border-slate-300"
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ />
</option> Nhập tay (không link)
))} </label>
</Select> </div>
<p className="text-[11px] text-slate-500"> {!budgetManual ? (
{!projectId <>
? 'Chọn dự án trước để xem ngân sách khả dụng.' <Select
: eligibleBudgets.data && eligibleBudgets.data.length === 0 value={budgetId}
? 'Dự án này chưa có ngân sách đã duyệt.' disabled={!projectId}
: 'Chỉ list ngân sách đã duyệt cùng dự án.'} onChange={e => setBudgetId(e.target.value)}
</p> >
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="text-[11px] text-slate-500">
{!projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<Input
value={budgetManualName}
onChange={e => setBudgetManualName(e.target.value)}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/>
</div>
<div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={budgetManualAmount || ''}
onChange={e => setBudgetManualAmount(Number(e.target.value))}
placeholder="1000000000"
/>
{budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div>
)}
</div> </div>
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<Button type="submit" disabled={create.isPending}> <Button type="submit" disabled={create.isPending}>
@ -497,6 +549,11 @@ function ContractEditForm({
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '') const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
const [noiDung, setNoiDung] = useState(contract.noiDung ?? '') const [noiDung, setNoiDung] = useState(contract.noiDung ?? '')
const [budgetId, setBudgetId] = useState(contract.budgetId ?? '') const [budgetId, setBudgetId] = useState(contract.budgetId ?? '')
// Mig 17 — manual budget fallback. Auto-toggle khi load có manual data
const hasInitialManual = contract.budgetManualName !== null || contract.budgetManualAmount !== null
const [budgetManual, setBudgetManual] = useState(hasInitialManual && !contract.budgetId)
const [budgetManualName, setBudgetManualName] = useState(contract.budgetManualName ?? '')
const [budgetManualAmount, setBudgetManualAmount] = useState(contract.budgetManualAmount ?? 0)
const templates = useQuery({ const templates = useQuery({
queryKey: ['templates-by-type', contract.type], queryKey: ['templates-by-type', contract.type],
@ -513,6 +570,10 @@ function ContractEditForm({
}) })
const qc = useQueryClient() const qc = useQueryClient()
const budgetPayload = budgetManual
? { budgetId: null, budgetManualName: budgetManualName || null, budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null }
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
const update = useMutation({ const update = useMutation({
mutationFn: async () => { mutationFn: async () => {
await api.put(`/contracts/${contract.id}`, { await api.put(`/contracts/${contract.id}`, {
@ -522,7 +583,7 @@ function ContractEditForm({
noiDung: noiDung || null, noiDung: noiDung || null,
templateId: templateId || null, templateId: templateId || null,
draftData: null, draftData: null,
budgetId: budgetId || null, ...budgetPayload,
}) })
}, },
onSuccess: () => { onSuccess: () => {
@ -611,23 +672,65 @@ function ContractEditForm({
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} /> <Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} />
</div> </div>
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">
<Label>Ngân sách (đi chiếu chi phí)</Label> <div className="flex items-center justify-between">
<Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label>
{isDraft && (
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
<input
type="checkbox"
checked={budgetManual}
onChange={e => setBudgetManual(e.target.checked)}
className="h-3.5 w-3.5 rounded border-slate-300"
/>
Nhập tay (không link)
</label>
)}
</div>
{isDraft ? ( {isDraft ? (
<> !budgetManual ? (
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)}> <>
<option value=""> (không link)</option> <Select value={budgetId} onChange={e => setBudgetId(e.target.value)}>
{eligibleBudgets.data?.map(b => ( <option value=""> (không link)</option>
<option key={b.id} value={b.id}> {eligibleBudgets.data?.map(b => (
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ <option key={b.id} value={b.id}>
</option> {b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
))} </option>
</Select> ))}
<p className="text-[11px] text-slate-500"> </Select>
{eligibleBudgets.data && eligibleBudgets.data.length === 0 <p className="text-[11px] text-slate-500">
? 'Dự án này chưa có ngân sách đã duyệt.' {eligibleBudgets.data && eligibleBudgets.data.length === 0
: 'Chỉ list ngân sách đã duyệt cùng dự án.'} ? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
</p> : 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</> </p>
</>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<Input
value={budgetManualName}
onChange={e => setBudgetManualName(e.target.value)}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/>
</div>
<div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={budgetManualAmount || ''}
onChange={e => setBudgetManualAmount(Number(e.target.value))}
placeholder="1000000000"
/>
{budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div>
)
) : contract.budget ? ( ) : contract.budget ? (
<a <a
href={`/budgets?id=${contract.budget.id}`} href={`/budgets?id=${contract.budget.id}`}
@ -637,6 +740,16 @@ function ContractEditForm({
{' · '}{contract.budget.tenNganSach} {' · '}{contract.budget.tenNganSach}
{' · '}<span className="text-slate-500">{contract.budget.tongNganSach.toLocaleString('vi-VN')} đ</span> {' · '}<span className="text-slate-500">{contract.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a> </a>
) : contract.budgetManualAmount != null || contract.budgetManualName ? (
// Mig 17 — read-only display khi !isDraft + có manual data
<div className="block rounded border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
{contract.budgetManualName && <span>{contract.budgetManualName}</span>}
{contract.budgetManualName && contract.budgetManualAmount != null && ' · '}
{contract.budgetManualAmount != null && (
<span className="font-semibold text-slate-900">{contract.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
)}
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
</div>
) : ( ) : (
<Input value="(không link)" disabled className="bg-slate-50" /> <Input value="(không link)" disabled className="bg-slate-50" />
)} )}

View File

@ -1,114 +1,18 @@
// Create / edit draft phiếu Duyệt NCC (Header only — Suppliers + Details + Quotes // Page Create / edit header phiếu Duyệt NCC riêng (deep-link "Sửa header"
// chỉnh sửa ở Detail tabs sau khi save). // button trong PeDetailTabs). Refactor 2026-05-07: wrap PeHeaderForm cho DRY
import { useEffect, useState } from 'react' // + auto support manual budget (Mig 17). NCC + Báo giá + Items vẫn chỉnh ở
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' // Detail tabs sau khi save.
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { ClipboardCheck } from 'lucide-react' import { ClipboardCheck } from 'lucide-react'
import { Button } from '@/components/ui/Button' import { PeHeaderForm } from '@/components/pe/PeHeaderForm'
import { Input } from '@/components/ui/Input' import { PurchaseEvaluationType } from '@/types/purchaseEvaluation'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import {
PurchaseEvaluationType,
PurchaseEvaluationTypeLabel,
type PeDetailBundle,
} from '@/types/purchaseEvaluation'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
import type { Paged, Project } from '@/types/master'
export function PurchaseEvaluationCreatePage() { export function PurchaseEvaluationCreatePage() {
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient()
const [sp] = useSearchParams() const [sp] = useSearchParams()
const editId = sp.get('id') const editId = sp.get('id')
const urlType = sp.get('type') ? Number(sp.get('type')) : PurchaseEvaluationType.DuyetNcc const urlType = sp.get('type') ? Number(sp.get('type')) : PurchaseEvaluationType.DuyetNcc
const projects = useQuery({
queryKey: ['all-projects'],
queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
})
const existing = useQuery({
queryKey: ['pe-detail', editId],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${editId}`)).data,
enabled: !!editId,
})
const [form, setForm] = useState({
type: urlType as number,
tenGoiThau: '',
projectId: '',
diaDiem: '',
moTa: '',
paymentTerms: '',
budgetId: '' as string,
})
// Eligible Budgets: cùng Project + Phase=DaDuyet. BE filter trên Project +
// Phase server-side để FE không phải lọc thêm.
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', form.projectId],
queryFn: async () => {
const res = await api.get<Paged<BudgetListItem>>('/budgets', {
params: {
pageSize: 100,
projectId: form.projectId,
phase: BudgetPhase.DaDuyet,
},
})
return res.data.items
},
enabled: !!form.projectId,
})
useEffect(() => {
if (existing.data) {
setForm({
type: existing.data.type,
tenGoiThau: existing.data.tenGoiThau,
projectId: existing.data.projectId,
diaDiem: existing.data.diaDiem ?? '',
moTa: existing.data.moTa ?? '',
paymentTerms: existing.data.paymentTerms ?? '',
budgetId: existing.data.budgetId ?? '',
})
}
}, [existing.data])
const mut = useMutation({
mutationFn: async () => {
if (editId) {
return api.put(`/purchase-evaluations/${editId}`, {
id: editId,
tenGoiThau: form.tenGoiThau,
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
budgetId: form.budgetId || null,
})
}
return api.post<{ id: string }>('/purchase-evaluations', {
type: form.type,
tenGoiThau: form.tenGoiThau,
projectId: form.projectId,
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
budgetId: form.budgetId || null,
})
},
onSuccess: res => {
toast.success(editId ? 'Đã lưu.' : 'Đã tạo phiếu.')
qc.invalidateQueries({ queryKey: ['pe-list'] })
const id = editId ?? (res as { data: { id: string } }).data.id
navigate(`/purchase-evaluations?id=${id}&type=${form.type}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
return ( return (
<div className="space-y-4 p-6"> <div className="space-y-4 p-6">
<header className="flex items-center gap-2"> <header className="flex items-center gap-2">
@ -118,104 +22,12 @@ export function PurchaseEvaluationCreatePage() {
</h1> </h1>
</header> </header>
<div className="max-w-2xl space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm"> <PeHeaderForm
<div> editId={editId}
<Label>Loại quy trình</Label> defaultType={urlType}
<Select onSaved={(id, type) => navigate(`/purchase-evaluations?id=${id}&type=${type}`)}
value={form.type} onCancel={() => navigate(-1)}
disabled={!!editId} />
onChange={e => setForm({ ...form, type: Number(e.target.value) })}
>
{Object.values(PurchaseEvaluationType).map(t => (
<option key={t} value={t}>{PurchaseEvaluationTypeLabel[t]}</option>
))}
</Select>
</div>
<div>
<Label>Tên gói thầu *</Label>
<Input
value={form.tenGoiThau}
onChange={e => setForm({ ...form, tenGoiThau: e.target.value })}
placeholder="vd Cung cấp bê tông"
/>
</div>
<div>
<Label>Dự án *</Label>
<Select
value={form.projectId}
disabled={!!editId}
onChange={e => setForm({ ...form, projectId: e.target.value })}
>
<option value="">-- Chọn --</option>
{projects.data?.map(p => (
<option key={p.id} value={p.id}>{p.code} {p.name}</option>
))}
</Select>
</div>
<div>
<Label>Ngân sách (đi chiếu chi phí)</Label>
<Select
value={form.budgetId}
disabled={!form.projectId}
onChange={e => setForm({ ...form, budgetId: e.target.value })}
>
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="mt-1 text-[11px] text-slate-500">
{!form.projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</div>
<div>
<Label>Đa điểm</Label>
<Input
value={form.diaDiem}
onChange={e => setForm({ ...form, diaDiem: e.target.value })}
placeholder="Lô K, KCN Lộc An - Bình Sơn..."
/>
</div>
<div>
<Label> tả</Label>
<Textarea
rows={3}
value={form.moTa}
onChange={e => setForm({ ...form, moTa: e.target.value })}
/>
</div>
<div>
<Label>Điều khoản thanh toán (JSON hoặc text)</Label>
<Textarea
rows={3}
value={form.paymentTerms}
onChange={e => setForm({ ...form, paymentTerms: e.target.value })}
placeholder='{"tamUng":"10%","thanhToanTam":"100% W.done","quyetToan":"Final Account","baoHanh":"5%"}'
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => navigate(-1)}>Hủy</Button>
<Button
onClick={() => mut.mutate()}
disabled={!form.tenGoiThau || !form.projectId || mut.isPending}
>
{editId ? 'Lưu' : 'Tạo phiếu'}
</Button>
</div>
</div>
</div> </div>
) )
} }

View File

@ -158,6 +158,9 @@ export type ContractDetail = {
updatedAt: string | null updatedAt: string | null
budgetId: string | null budgetId: string | null
budget: ContractBudgetSummary | null budget: ContractBudgetSummary | null
// Mig 17 — manual budget fallback khi không link Budget entity.
budgetManualName: string | null
budgetManualAmount: number | null
approvals: ContractApproval[] approvals: ContractApproval[]
comments: ContractComment[] comments: ContractComment[]
attachments: ContractAttachment[] attachments: ContractAttachment[]

View File

@ -252,6 +252,9 @@ export type PeDetailBundle = {
updatedAt: string | null updatedAt: string | null
budgetId: string | null budgetId: string | null
budget: BudgetSummary | null budget: BudgetSummary | null
// Mig 17 — manual budget fallback khi không link Budget entity. Cả 2 cùng null OK.
budgetManualName: string | null
budgetManualAmount: number | null
suppliers: PeSupplier[] suppliers: PeSupplier[]
details: PeDetailRow[] details: PeDetailRow[]
approvals: PeApproval[] approvals: PeApproval[]

View File

@ -343,6 +343,17 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
{' · '}{ev.budget.tenNganSach} {' · '}{ev.budget.tenNganSach}
{' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span> {' · '}<span className="text-slate-500">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a> </a>
) : ev.budgetManualAmount != null || ev.budgetManualName ? (
// Mig 17 — manual budget fallback: hiển thị tên + số tiền nhập tay,
// không phải link vào /budgets/{id} (không có Budget entity).
<span className="text-slate-700">
{ev.budgetManualName && <span>{ev.budgetManualName}</span>}
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '}
{ev.budgetManualAmount != null && (
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
)}
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
</span>
) : <span className="text-slate-400"> (chưa link)</span>} ) : <span className="text-slate-400"> (chưa link)</span>}
/> />
<FormRow <FormRow

View File

@ -53,6 +53,10 @@ export function PeHeaderForm({
moTa: '', moTa: '',
paymentTerms: '', paymentTerms: '',
budgetId: '' as string, budgetId: '' as string,
// Mig 17 — manual budget fallback (toggle "Nhập tay" thay vì link)
budgetManual: false,
budgetManualName: '',
budgetManualAmount: 0,
}) })
const eligibleBudgets = useQuery({ const eligibleBudgets = useQuery({
@ -68,6 +72,8 @@ export function PeHeaderForm({
useEffect(() => { useEffect(() => {
if (existing.data) { if (existing.data) {
const hasManual = existing.data.budgetManualName !== null
|| existing.data.budgetManualAmount !== null
setForm({ setForm({
type: existing.data.type, type: existing.data.type,
tenGoiThau: existing.data.tenGoiThau, tenGoiThau: existing.data.tenGoiThau,
@ -76,10 +82,27 @@ export function PeHeaderForm({
moTa: existing.data.moTa ?? '', moTa: existing.data.moTa ?? '',
paymentTerms: existing.data.paymentTerms ?? '', paymentTerms: existing.data.paymentTerms ?? '',
budgetId: existing.data.budgetId ?? '', budgetId: existing.data.budgetId ?? '',
// Auto-toggle manual mode khi load existing có manual data hoặc không có link
budgetManual: hasManual && !existing.data.budgetId,
budgetManualName: existing.data.budgetManualName ?? '',
budgetManualAmount: existing.data.budgetManualAmount ?? 0,
}) })
} }
}, [existing.data]) }, [existing.data])
// Manual mode: clear budgetId, gửi manualName/Amount. Link mode: clear manual.
const payloadBudgetFields = form.budgetManual
? {
budgetId: null,
budgetManualName: form.budgetManualName || null,
budgetManualAmount: form.budgetManualAmount > 0 ? form.budgetManualAmount : null,
}
: {
budgetId: form.budgetId || null,
budgetManualName: null,
budgetManualAmount: null,
}
const mut = useMutation({ const mut = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (editId) { if (editId) {
@ -89,7 +112,7 @@ export function PeHeaderForm({
diaDiem: form.diaDiem || null, diaDiem: form.diaDiem || null,
moTa: form.moTa || null, moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null, paymentTerms: form.paymentTerms || null,
budgetId: form.budgetId || null, ...payloadBudgetFields,
}) })
} }
return api.post<{ id: string }>('/purchase-evaluations', { return api.post<{ id: string }>('/purchase-evaluations', {
@ -99,7 +122,7 @@ export function PeHeaderForm({
diaDiem: form.diaDiem || null, diaDiem: form.diaDiem || null,
moTa: form.moTa || null, moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null, paymentTerms: form.paymentTerms || null,
budgetId: form.budgetId || null, ...payloadBudgetFields,
}) })
}, },
onSuccess: res => { onSuccess: res => {
@ -161,26 +184,69 @@ export function PeHeaderForm({
</div> </div>
<div> <div>
<Label>Ngân sách (đi chiếu chi phí)</Label> <div className="mb-1.5 flex items-center justify-between">
<Select <Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label>
value={form.budgetId} {/* Toggle "Nhập tay" — Mig 17 fallback khi không link Budget entity */}
disabled={!form.projectId} <label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
onChange={e => setForm({ ...form, budgetId: e.target.value })} <input
> type="checkbox"
<option value=""> (không link)</option> checked={form.budgetManual}
{eligibleBudgets.data?.map(b => ( onChange={e => setForm({ ...form, budgetManual: e.target.checked })}
<option key={b.id} value={b.id}> className="h-3.5 w-3.5 rounded border-slate-300"
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ />
</option> Nhập tay (không link)
))} </label>
</Select> </div>
<p className="mt-1 text-[11px] text-slate-500"> {!form.budgetManual ? (
{!form.projectId <>
? 'Chọn dự án trước để xem ngân sách khả dụng.' <Select
: eligibleBudgets.data && eligibleBudgets.data.length === 0 value={form.budgetId}
? 'Dự án này chưa có ngân sách đã duyệt.' disabled={!form.projectId}
: 'Chỉ list ngân sách đã duyệt cùng dự án.'} onChange={e => setForm({ ...form, budgetId: e.target.value })}
</p> >
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="mt-1 text-[11px] text-slate-500">
{!form.projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<Input
value={form.budgetManualName}
onChange={e => setForm({ ...form, budgetManualName: e.target.value })}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/>
</div>
<div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={form.budgetManualAmount || ''}
onChange={e => setForm({ ...form, budgetManualAmount: Number(e.target.value) })}
placeholder="1000000000"
/>
{form.budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{form.budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div>
)}
</div> </div>
<div> <div>

View File

@ -305,6 +305,10 @@ function ContractHeaderForm({
const [noiDung, setNoiDung] = useState('') const [noiDung, setNoiDung] = useState('')
const [bypass, setBypass] = useState(false) const [bypass, setBypass] = useState(false)
const [budgetId, setBudgetId] = useState('') const [budgetId, setBudgetId] = useState('')
// Mig 17 — manual budget fallback (toggle "Nhập tay")
const [budgetManual, setBudgetManual] = useState(false)
const [budgetManualName, setBudgetManualName] = useState('')
const [budgetManualAmount, setBudgetManualAmount] = useState(0)
// Reset type về default khi typeFilter (parent prop) thay đổi // Reset type về default khi typeFilter (parent prop) thay đổi
useEffect(() => { setType(defaultType) }, [defaultType]) useEffect(() => { setType(defaultType) }, [defaultType])
@ -334,6 +338,11 @@ function ContractHeaderForm({
}) })
const qc = useQueryClient() const qc = useQueryClient()
// Manual mode: clear budgetId, gửi manualName/Amount. Link mode: clear manual.
const budgetPayload = budgetManual
? { budgetId: null, budgetManualName: budgetManualName || null, budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null }
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
const res = await api.post<{ id: string }>('/contracts', { const res = await api.post<{ id: string }>('/contracts', {
@ -347,7 +356,7 @@ function ContractHeaderForm({
noiDung: noiDung || null, noiDung: noiDung || null,
bypassProcurementAndCCM: bypass, bypassProcurementAndCCM: bypass,
draftData: null, draftData: null,
budgetId: budgetId || null, ...budgetPayload,
}) })
return res.data.id return res.data.id
}, },
@ -387,26 +396,69 @@ function ContractHeaderForm({
typeReadonly={false} typeReadonly={false}
/> />
<div className="mt-4 space-y-1.5"> <div className="mt-4 space-y-1.5">
<Label>Ngân sách (đi chiếu chi phí)</Label> <div className="flex items-center justify-between">
<Select <Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label>
value={budgetId} {/* Toggle "Nhập tay" — Mig 17 fallback khi không link Budget entity */}
disabled={!projectId} <label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
onChange={e => setBudgetId(e.target.value)} <input
> type="checkbox"
<option value=""> (không link)</option> checked={budgetManual}
{eligibleBudgets.data?.map(b => ( onChange={e => setBudgetManual(e.target.checked)}
<option key={b.id} value={b.id}> className="h-3.5 w-3.5 rounded border-slate-300"
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ />
</option> Nhập tay (không link)
))} </label>
</Select> </div>
<p className="text-[11px] text-slate-500"> {!budgetManual ? (
{!projectId <>
? 'Chọn dự án trước để xem ngân sách khả dụng.' <Select
: eligibleBudgets.data && eligibleBudgets.data.length === 0 value={budgetId}
? 'Dự án này chưa có ngân sách đã duyệt.' disabled={!projectId}
: 'Chỉ list ngân sách đã duyệt cùng dự án.'} onChange={e => setBudgetId(e.target.value)}
</p> >
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="text-[11px] text-slate-500">
{!projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<Input
value={budgetManualName}
onChange={e => setBudgetManualName(e.target.value)}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/>
</div>
<div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={budgetManualAmount || ''}
onChange={e => setBudgetManualAmount(Number(e.target.value))}
placeholder="1000000000"
/>
{budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div>
)}
</div> </div>
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<Button type="submit" disabled={create.isPending}> <Button type="submit" disabled={create.isPending}>
@ -497,6 +549,11 @@ function ContractEditForm({
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '') const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
const [noiDung, setNoiDung] = useState(contract.noiDung ?? '') const [noiDung, setNoiDung] = useState(contract.noiDung ?? '')
const [budgetId, setBudgetId] = useState(contract.budgetId ?? '') const [budgetId, setBudgetId] = useState(contract.budgetId ?? '')
// Mig 17 — manual budget fallback. Auto-toggle khi load có manual data
const hasInitialManual = contract.budgetManualName !== null || contract.budgetManualAmount !== null
const [budgetManual, setBudgetManual] = useState(hasInitialManual && !contract.budgetId)
const [budgetManualName, setBudgetManualName] = useState(contract.budgetManualName ?? '')
const [budgetManualAmount, setBudgetManualAmount] = useState(contract.budgetManualAmount ?? 0)
const templates = useQuery({ const templates = useQuery({
queryKey: ['templates-by-type', contract.type], queryKey: ['templates-by-type', contract.type],
@ -513,6 +570,10 @@ function ContractEditForm({
}) })
const qc = useQueryClient() const qc = useQueryClient()
const budgetPayload = budgetManual
? { budgetId: null, budgetManualName: budgetManualName || null, budgetManualAmount: budgetManualAmount > 0 ? budgetManualAmount : null }
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
const update = useMutation({ const update = useMutation({
mutationFn: async () => { mutationFn: async () => {
await api.put(`/contracts/${contract.id}`, { await api.put(`/contracts/${contract.id}`, {
@ -522,7 +583,7 @@ function ContractEditForm({
noiDung: noiDung || null, noiDung: noiDung || null,
templateId: templateId || null, templateId: templateId || null,
draftData: null, draftData: null,
budgetId: budgetId || null, ...budgetPayload,
}) })
}, },
onSuccess: () => { onSuccess: () => {
@ -611,23 +672,65 @@ function ContractEditForm({
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} /> <Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} />
</div> </div>
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">
<Label>Ngân sách (đi chiếu chi phí)</Label> <div className="flex items-center justify-between">
<Label className="mb-0">Ngân sách (đi chiếu chi phí)</Label>
{isDraft && (
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
<input
type="checkbox"
checked={budgetManual}
onChange={e => setBudgetManual(e.target.checked)}
className="h-3.5 w-3.5 rounded border-slate-300"
/>
Nhập tay (không link)
</label>
)}
</div>
{isDraft ? ( {isDraft ? (
<> !budgetManual ? (
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)}> <>
<option value=""> (không link)</option> <Select value={budgetId} onChange={e => setBudgetId(e.target.value)}>
{eligibleBudgets.data?.map(b => ( <option value=""> (không link)</option>
<option key={b.id} value={b.id}> {eligibleBudgets.data?.map(b => (
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ <option key={b.id} value={b.id}>
</option> {b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
))} </option>
</Select> ))}
<p className="text-[11px] text-slate-500"> </Select>
{eligibleBudgets.data && eligibleBudgets.data.length === 0 <p className="text-[11px] text-slate-500">
? 'Dự án này chưa có ngân sách đã duyệt.' {eligibleBudgets.data && eligibleBudgets.data.length === 0
: 'Chỉ list ngân sách đã duyệt cùng dự án.'} ? 'Dự án này chưa có ngân sách đã duyệt — bật "Nhập tay" để nhập số tiền trực tiếp.'
</p> : 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</> </p>
</>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<div>
<Label className="text-[11px]">Tên ngân sách (tham chiếu)</Label>
<Input
value={budgetManualName}
onChange={e => setBudgetManualName(e.target.value)}
placeholder="vd Tạm tính dự toán T11/2025"
maxLength={200}
/>
</div>
<div>
<Label className="text-[11px]">Số tiền (đ)</Label>
<Input
type="number"
min={0}
value={budgetManualAmount || ''}
onChange={e => setBudgetManualAmount(Number(e.target.value))}
placeholder="1000000000"
/>
{budgetManualAmount > 0 && (
<p className="mt-1 text-[11px] text-slate-500">
{budgetManualAmount.toLocaleString('vi-VN')} đ
</p>
)}
</div>
</div>
)
) : contract.budget ? ( ) : contract.budget ? (
<a <a
href={`/budgets?id=${contract.budget.id}`} href={`/budgets?id=${contract.budget.id}`}
@ -637,6 +740,16 @@ function ContractEditForm({
{' · '}{contract.budget.tenNganSach} {' · '}{contract.budget.tenNganSach}
{' · '}<span className="text-slate-500">{contract.budget.tongNganSach.toLocaleString('vi-VN')} đ</span> {' · '}<span className="text-slate-500">{contract.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
</a> </a>
) : contract.budgetManualAmount != null || contract.budgetManualName ? (
// Mig 17 — read-only display khi !isDraft + có manual data
<div className="block rounded border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
{contract.budgetManualName && <span>{contract.budgetManualName}</span>}
{contract.budgetManualName && contract.budgetManualAmount != null && ' · '}
{contract.budgetManualAmount != null && (
<span className="font-semibold text-slate-900">{contract.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
)}
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
</div>
) : ( ) : (
<Input value="(không link)" disabled className="bg-slate-50" /> <Input value="(không link)" disabled className="bg-slate-50" />
)} )}

View File

@ -1,114 +1,18 @@
// Create / edit draft phiếu Duyệt NCC (Header only — Suppliers + Details + Quotes // Page Create / edit header phiếu Duyệt NCC riêng (deep-link "Sửa header"
// chỉnh sửa ở Detail tabs sau khi save). // button trong PeDetailTabs). Refactor 2026-05-07: wrap PeHeaderForm cho DRY
import { useEffect, useState } from 'react' // + auto support manual budget (Mig 17). NCC + Báo giá + Items vẫn chỉnh ở
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' // Detail tabs sau khi save.
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { ClipboardCheck } from 'lucide-react' import { ClipboardCheck } from 'lucide-react'
import { Button } from '@/components/ui/Button' import { PeHeaderForm } from '@/components/pe/PeHeaderForm'
import { Input } from '@/components/ui/Input' import { PurchaseEvaluationType } from '@/types/purchaseEvaluation'
import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import {
PurchaseEvaluationType,
PurchaseEvaluationTypeLabel,
type PeDetailBundle,
} from '@/types/purchaseEvaluation'
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
import type { Paged, Project } from '@/types/master'
export function PurchaseEvaluationCreatePage() { export function PurchaseEvaluationCreatePage() {
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient()
const [sp] = useSearchParams() const [sp] = useSearchParams()
const editId = sp.get('id') const editId = sp.get('id')
const urlType = sp.get('type') ? Number(sp.get('type')) : PurchaseEvaluationType.DuyetNcc const urlType = sp.get('type') ? Number(sp.get('type')) : PurchaseEvaluationType.DuyetNcc
const projects = useQuery({
queryKey: ['all-projects'],
queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
})
const existing = useQuery({
queryKey: ['pe-detail', editId],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${editId}`)).data,
enabled: !!editId,
})
const [form, setForm] = useState({
type: urlType as number,
tenGoiThau: '',
projectId: '',
diaDiem: '',
moTa: '',
paymentTerms: '',
budgetId: '' as string,
})
// Eligible Budgets: cùng Project + Phase=DaDuyet. BE filter trên Project +
// Phase server-side để FE không phải lọc thêm.
const eligibleBudgets = useQuery({
queryKey: ['eligible-budgets', form.projectId],
queryFn: async () => {
const res = await api.get<Paged<BudgetListItem>>('/budgets', {
params: {
pageSize: 100,
projectId: form.projectId,
phase: BudgetPhase.DaDuyet,
},
})
return res.data.items
},
enabled: !!form.projectId,
})
useEffect(() => {
if (existing.data) {
setForm({
type: existing.data.type,
tenGoiThau: existing.data.tenGoiThau,
projectId: existing.data.projectId,
diaDiem: existing.data.diaDiem ?? '',
moTa: existing.data.moTa ?? '',
paymentTerms: existing.data.paymentTerms ?? '',
budgetId: existing.data.budgetId ?? '',
})
}
}, [existing.data])
const mut = useMutation({
mutationFn: async () => {
if (editId) {
return api.put(`/purchase-evaluations/${editId}`, {
id: editId,
tenGoiThau: form.tenGoiThau,
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
budgetId: form.budgetId || null,
})
}
return api.post<{ id: string }>('/purchase-evaluations', {
type: form.type,
tenGoiThau: form.tenGoiThau,
projectId: form.projectId,
diaDiem: form.diaDiem || null,
moTa: form.moTa || null,
paymentTerms: form.paymentTerms || null,
budgetId: form.budgetId || null,
})
},
onSuccess: res => {
toast.success(editId ? 'Đã lưu.' : 'Đã tạo phiếu.')
qc.invalidateQueries({ queryKey: ['pe-list'] })
const id = editId ?? (res as { data: { id: string } }).data.id
navigate(`/purchase-evaluations?id=${id}&type=${form.type}`)
},
onError: e => toast.error(getErrorMessage(e)),
})
return ( return (
<div className="space-y-4 p-6"> <div className="space-y-4 p-6">
<header className="flex items-center gap-2"> <header className="flex items-center gap-2">
@ -118,104 +22,12 @@ export function PurchaseEvaluationCreatePage() {
</h1> </h1>
</header> </header>
<div className="max-w-2xl space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm"> <PeHeaderForm
<div> editId={editId}
<Label>Loại quy trình</Label> defaultType={urlType}
<Select onSaved={(id, type) => navigate(`/purchase-evaluations?id=${id}&type=${type}`)}
value={form.type} onCancel={() => navigate(-1)}
disabled={!!editId} />
onChange={e => setForm({ ...form, type: Number(e.target.value) })}
>
{Object.values(PurchaseEvaluationType).map(t => (
<option key={t} value={t}>{PurchaseEvaluationTypeLabel[t]}</option>
))}
</Select>
</div>
<div>
<Label>Tên gói thầu *</Label>
<Input
value={form.tenGoiThau}
onChange={e => setForm({ ...form, tenGoiThau: e.target.value })}
placeholder="vd Cung cấp bê tông"
/>
</div>
<div>
<Label>Dự án *</Label>
<Select
value={form.projectId}
disabled={!!editId}
onChange={e => setForm({ ...form, projectId: e.target.value })}
>
<option value="">-- Chọn --</option>
{projects.data?.map(p => (
<option key={p.id} value={p.id}>{p.code} {p.name}</option>
))}
</Select>
</div>
<div>
<Label>Ngân sách (đi chiếu chi phí)</Label>
<Select
value={form.budgetId}
disabled={!form.projectId}
onChange={e => setForm({ ...form, budgetId: e.target.value })}
>
<option value=""> (không link)</option>
{eligibleBudgets.data?.map(b => (
<option key={b.id} value={b.id}>
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
</option>
))}
</Select>
<p className="mt-1 text-[11px] text-slate-500">
{!form.projectId
? 'Chọn dự án trước để xem ngân sách khả dụng.'
: eligibleBudgets.data && eligibleBudgets.data.length === 0
? 'Dự án này chưa có ngân sách đã duyệt.'
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
</p>
</div>
<div>
<Label>Đa điểm</Label>
<Input
value={form.diaDiem}
onChange={e => setForm({ ...form, diaDiem: e.target.value })}
placeholder="Lô K, KCN Lộc An - Bình Sơn..."
/>
</div>
<div>
<Label> tả</Label>
<Textarea
rows={3}
value={form.moTa}
onChange={e => setForm({ ...form, moTa: e.target.value })}
/>
</div>
<div>
<Label>Điều khoản thanh toán (JSON hoặc text)</Label>
<Textarea
rows={3}
value={form.paymentTerms}
onChange={e => setForm({ ...form, paymentTerms: e.target.value })}
placeholder='{"tamUng":"10%","thanhToanTam":"100% W.done","quyetToan":"Final Account","baoHanh":"5%"}'
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => navigate(-1)}>Hủy</Button>
<Button
onClick={() => mut.mutate()}
disabled={!form.tenGoiThau || !form.projectId || mut.isPending}
>
{editId ? 'Lưu' : 'Tạo phiếu'}
</Button>
</div>
</div>
</div> </div>
) )
} }

View File

@ -158,6 +158,9 @@ export type ContractDetail = {
updatedAt: string | null updatedAt: string | null
budgetId: string | null budgetId: string | null
budget: ContractBudgetSummary | null budget: ContractBudgetSummary | null
// Mig 17 — manual budget fallback khi không link Budget entity.
budgetManualName: string | null
budgetManualAmount: number | null
approvals: ContractApproval[] approvals: ContractApproval[]
comments: ContractComment[] comments: ContractComment[]
attachments: ContractAttachment[] attachments: ContractAttachment[]

View File

@ -252,6 +252,9 @@ export type PeDetailBundle = {
updatedAt: string | null updatedAt: string | null
budgetId: string | null budgetId: string | null
budget: BudgetSummary | null budget: BudgetSummary | null
// Mig 17 — manual budget fallback khi không link Budget entity. Cả 2 cùng null OK.
budgetManualName: string | null
budgetManualAmount: number | null
suppliers: PeSupplier[] suppliers: PeSupplier[]
details: PeDetailRow[] details: PeDetailRow[]
approvals: PeApproval[] approvals: PeApproval[]

View File

@ -25,7 +25,9 @@ public record CreateContractCommand(
string? NoiDung, string? NoiDung,
bool BypassProcurementAndCCM, bool BypassProcurementAndCCM,
string? DraftData, string? DraftData,
Guid? BudgetId) : IRequest<Guid>; Guid? BudgetId,
string? BudgetManualName,
decimal? BudgetManualAmount) : IRequest<Guid>;
public class CreateContractCommandValidator : AbstractValidator<CreateContractCommand> public class CreateContractCommandValidator : AbstractValidator<CreateContractCommand>
{ {
@ -37,6 +39,8 @@ public class CreateContractCommandValidator : AbstractValidator<CreateContractCo
RuleFor(x => x.GiaTri).GreaterThanOrEqualTo(0); RuleFor(x => x.GiaTri).GreaterThanOrEqualTo(0);
RuleFor(x => x.TenHopDong).MaximumLength(500); RuleFor(x => x.TenHopDong).MaximumLength(500);
RuleFor(x => x.NoiDung).MaximumLength(2000); RuleFor(x => x.NoiDung).MaximumLength(2000);
RuleFor(x => x.BudgetManualName).MaximumLength(200);
RuleFor(x => x.BudgetManualAmount).GreaterThanOrEqualTo(0).When(x => x.BudgetManualAmount.HasValue);
} }
} }
@ -87,6 +91,8 @@ public class CreateContractCommandHandler(
BypassProcurementAndCCM = request.BypassProcurementAndCCM, BypassProcurementAndCCM = request.BypassProcurementAndCCM,
DraftData = request.DraftData, DraftData = request.DraftData,
BudgetId = request.BudgetId, BudgetId = request.BudgetId,
BudgetManualName = request.BudgetManualName,
BudgetManualAmount = request.BudgetManualAmount,
WorkflowDefinitionId = activeWfId, WorkflowDefinitionId = activeWfId,
SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)), SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
}; };
@ -121,7 +127,9 @@ public record UpdateContractDraftCommand(
string? NoiDung, string? NoiDung,
Guid? TemplateId, Guid? TemplateId,
string? DraftData, string? DraftData,
Guid? BudgetId) : IRequest; Guid? BudgetId,
string? BudgetManualName,
decimal? BudgetManualAmount) : IRequest;
public class UpdateContractDraftCommandHandler( public class UpdateContractDraftCommandHandler(
IApplicationDbContext db, IApplicationDbContext db,
@ -159,6 +167,10 @@ public class UpdateContractDraftCommandHandler(
changes.Add(new { Field = "TemplateId", Old = entity.TemplateId, New = request.TemplateId }); changes.Add(new { Field = "TemplateId", Old = entity.TemplateId, New = request.TemplateId });
if (entity.BudgetId != request.BudgetId) if (entity.BudgetId != request.BudgetId)
changes.Add(new { Field = "BudgetId", Old = entity.BudgetId, New = request.BudgetId }); changes.Add(new { Field = "BudgetId", Old = entity.BudgetId, New = request.BudgetId });
if (entity.BudgetManualName != request.BudgetManualName)
changes.Add(new { Field = "BudgetManualName", Old = entity.BudgetManualName, New = request.BudgetManualName });
if (entity.BudgetManualAmount != request.BudgetManualAmount)
changes.Add(new { Field = "BudgetManualAmount", Old = entity.BudgetManualAmount, New = request.BudgetManualAmount });
entity.GiaTri = request.GiaTri; entity.GiaTri = request.GiaTri;
entity.TenHopDong = request.TenHopDong; entity.TenHopDong = request.TenHopDong;
@ -166,6 +178,8 @@ public class UpdateContractDraftCommandHandler(
entity.TemplateId = request.TemplateId; entity.TemplateId = request.TemplateId;
entity.DraftData = request.DraftData; entity.DraftData = request.DraftData;
entity.BudgetId = request.BudgetId; entity.BudgetId = request.BudgetId;
entity.BudgetManualName = request.BudgetManualName;
entity.BudgetManualAmount = request.BudgetManualAmount;
if (changes.Count > 0) if (changes.Count > 0)
{ {
@ -471,6 +485,7 @@ public class GetContractQueryHandler(
c.TemplateId, c.GiaTri, c.BypassProcurementAndCCM, c.SlaDeadline, c.DraftData, c.TemplateId, c.GiaTri, c.BypassProcurementAndCCM, c.SlaDeadline, c.DraftData,
c.CreatedAt, c.UpdatedAt, c.CreatedAt, c.UpdatedAt,
c.BudgetId, budgetSummary, c.BudgetId, budgetSummary,
c.BudgetManualName, c.BudgetManualAmount,
c.Approvals c.Approvals
.OrderBy(a => a.ApprovedAt) .OrderBy(a => a.ApprovedAt)
.Select(a => new ContractApprovalDto( .Select(a => new ContractApprovalDto(

View File

@ -41,6 +41,8 @@ public record ContractDetailDto(
DateTime? UpdatedAt, DateTime? UpdatedAt,
Guid? BudgetId, Guid? BudgetId,
BudgetSummaryDto? Budget, BudgetSummaryDto? Budget,
string? BudgetManualName,
decimal? BudgetManualAmount,
List<ContractApprovalDto> Approvals, List<ContractApprovalDto> Approvals,
List<ContractCommentDto> Comments, List<ContractCommentDto> Comments,
List<ContractAttachmentDto> Attachments, List<ContractAttachmentDto> Attachments,

View File

@ -80,6 +80,8 @@ public class CreateContractFromEvaluationCommandHandler(
BypassProcurementAndCCM = request.BypassProcurementAndCCM, BypassProcurementAndCCM = request.BypassProcurementAndCCM,
DraftData = pe.PaymentTerms, // carry forward payment terms DraftData = pe.PaymentTerms, // carry forward payment terms
BudgetId = pe.BudgetId, // carry forward Budget link nếu PE đã link BudgetId = pe.BudgetId, // carry forward Budget link nếu PE đã link
BudgetManualName = pe.BudgetManualName, // carry forward manual budget (Mig 17)
BudgetManualAmount = pe.BudgetManualAmount,
WorkflowDefinitionId = activeWfId, WorkflowDefinitionId = activeWfId,
SlaDeadline = DateTime.UtcNow.Add( SlaDeadline = DateTime.UtcNow.Add(
workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)), workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),

View File

@ -127,6 +127,8 @@ public record PurchaseEvaluationDetailBundleDto(
DateTime? UpdatedAt, DateTime? UpdatedAt,
Guid? BudgetId, Guid? BudgetId,
BudgetSummaryDto? Budget, BudgetSummaryDto? Budget,
string? BudgetManualName,
decimal? BudgetManualAmount,
List<PurchaseEvaluationSupplierDto> Suppliers, List<PurchaseEvaluationSupplierDto> Suppliers,
List<PurchaseEvaluationDetailDto> Details, List<PurchaseEvaluationDetailDto> Details,
List<PurchaseEvaluationApprovalDto> Approvals, List<PurchaseEvaluationApprovalDto> Approvals,

View File

@ -23,7 +23,9 @@ public record CreatePurchaseEvaluationCommand(
string? DiaDiem, string? DiaDiem,
string? MoTa, string? MoTa,
string? PaymentTerms, string? PaymentTerms,
Guid? BudgetId) : IRequest<Guid>; Guid? BudgetId,
string? BudgetManualName,
decimal? BudgetManualAmount) : IRequest<Guid>;
public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<CreatePurchaseEvaluationCommand> public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<CreatePurchaseEvaluationCommand>
{ {
@ -34,6 +36,8 @@ public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<Create
RuleFor(x => x.ProjectId).NotEmpty(); RuleFor(x => x.ProjectId).NotEmpty();
RuleFor(x => x.DiaDiem).MaximumLength(500); RuleFor(x => x.DiaDiem).MaximumLength(500);
RuleFor(x => x.MoTa).MaximumLength(2000); RuleFor(x => x.MoTa).MaximumLength(2000);
RuleFor(x => x.BudgetManualName).MaximumLength(200);
RuleFor(x => x.BudgetManualAmount).GreaterThanOrEqualTo(0).When(x => x.BudgetManualAmount.HasValue);
} }
} }
@ -79,6 +83,8 @@ public class CreatePurchaseEvaluationCommandHandler(
WorkflowDefinitionId = activeWfId, WorkflowDefinitionId = activeWfId,
PaymentTerms = request.PaymentTerms, PaymentTerms = request.PaymentTerms,
BudgetId = request.BudgetId, BudgetId = request.BudgetId,
BudgetManualName = request.BudgetManualName,
BudgetManualAmount = request.BudgetManualAmount,
SlaDeadline = DateTime.UtcNow.Add( SlaDeadline = DateTime.UtcNow.Add(
workflow.GetPhaseSla(PurchaseEvaluationPhase.DangSoanThao) ?? TimeSpan.FromDays(3)), workflow.GetPhaseSla(PurchaseEvaluationPhase.DangSoanThao) ?? TimeSpan.FromDays(3)),
}; };
@ -112,7 +118,9 @@ public record UpdatePurchaseEvaluationDraftCommand(
string? DiaDiem, string? DiaDiem,
string? MoTa, string? MoTa,
string? PaymentTerms, string? PaymentTerms,
Guid? BudgetId) : IRequest; Guid? BudgetId,
string? BudgetManualName,
decimal? BudgetManualAmount) : IRequest;
public class UpdatePurchaseEvaluationDraftCommandHandler( public class UpdatePurchaseEvaluationDraftCommandHandler(
IApplicationDbContext db, IApplicationDbContext db,
@ -143,6 +151,8 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
entity.MoTa = request.MoTa; entity.MoTa = request.MoTa;
entity.PaymentTerms = request.PaymentTerms; entity.PaymentTerms = request.PaymentTerms;
entity.BudgetId = request.BudgetId; entity.BudgetId = request.BudgetId;
entity.BudgetManualName = request.BudgetManualName;
entity.BudgetManualAmount = request.BudgetManualAmount;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{ {
@ -408,6 +418,7 @@ public class GetPurchaseEvaluationQueryHandler(
e.ContractId, e.ContractId,
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt, e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.BudgetId, budgetSummary, e.BudgetId, budgetSummary,
e.BudgetManualName, e.BudgetManualAmount,
e.Suppliers e.Suppliers
.OrderBy(s => s.Order) .OrderBy(s => s.Order)
.Select(s => new PurchaseEvaluationSupplierDto( .Select(s => new PurchaseEvaluationSupplierDto(

View File

@ -25,6 +25,11 @@ public class Contract : AuditableEntity
public bool SlaWarningSent { get; set; } // Flag để không gửi warning 2 lần public bool SlaWarningSent { get; set; } // Flag để không gửi warning 2 lần
public Guid? BudgetId { get; set; } // Reference Budget (Phase 7) — đối chiếu chi phí HĐ vs ngân sách public Guid? BudgetId { get; set; } // Reference Budget (Phase 7) — đối chiếu chi phí HĐ vs ngân sách
// Manual budget fields (Phase 9 — Migration 17 mirror PE): user nhập tay khi
// KHÔNG link Budget entity. Cả 2 cùng null OK. KHÔNG XOR validate với BudgetId.
public string? BudgetManualName { get; set; } // Tên tham chiếu
public decimal? BudgetManualAmount { get; set; } // Tổng số tiền nhập tay (đ)
// Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject. Drafter // Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject. Drafter
// sửa lại + trình lại → quay về RejectedFromPhase thay vì DangSoanThao // sửa lại + trình lại → quay về RejectedFromPhase thay vì DangSoanThao
// tuần tự lại từ đầu. Null khi chưa từng reject hoặc đã trình lại xong. // tuần tự lại từ đầu. Null khi chưa từng reject hoặc đã trình lại xong.

View File

@ -28,6 +28,14 @@ public class PurchaseEvaluation : AuditableEntity
public Guid? ContractId { get; set; } // FK Contracts — set khi user gen HĐ từ phiếu public Guid? ContractId { get; set; } // FK Contracts — set khi user gen HĐ từ phiếu
public Guid? BudgetId { get; set; } // FK Budget (Phase 7) — đối chiếu báo giá vs ngân sách gói thầu public Guid? BudgetId { get; set; } // FK Budget (Phase 7) — đối chiếu báo giá vs ngân sách gói thầu
// Manual budget fields (Phase 9 — Migration 17): user nhập tay khi KHÔNG link
// Budget entity (vd ngân sách chưa approve hoặc gói nhỏ ko cần workflow ngân
// sách riêng). Cả 2 cùng null OK (PE chưa có ngân sách gì cả). KHÔNG validate
// XOR với BudgetId — tạm thời cho phép cả 2 cùng có (BE prefer BudgetId nếu
// set vì có Phase=DaDuyet guarantee, manual chỉ là fallback hiển thị).
public string? BudgetManualName { get; set; } // Tên tham chiếu vd "Tạm tính dự toán T11/2025"
public decimal? BudgetManualAmount { get; set; } // Tổng số tiền nhập tay (đ)
// Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject. Drafter // Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject. Drafter
// sửa lại + trình lại → quay về RejectedFromPhase thay vì đi tuần tự. // sửa lại + trình lại → quay về RejectedFromPhase thay vì đi tuần tự.
public PurchaseEvaluationPhase? RejectedFromPhase { get; set; } public PurchaseEvaluationPhase? RejectedFromPhase { get; set; }

View File

@ -19,6 +19,8 @@ public class ContractConfiguration : IEntityTypeConfiguration<Contract>
b.Property(x => x.TenHopDong).HasMaxLength(500); b.Property(x => x.TenHopDong).HasMaxLength(500);
b.Property(x => x.NoiDung).HasMaxLength(2000); b.Property(x => x.NoiDung).HasMaxLength(2000);
b.Property(x => x.DraftData).HasColumnType("nvarchar(max)"); b.Property(x => x.DraftData).HasColumnType("nvarchar(max)");
b.Property(x => x.BudgetManualName).HasMaxLength(200);
b.Property(x => x.BudgetManualAmount).HasPrecision(18, 2);
b.HasIndex(x => x.MaHopDong).IsUnique().HasFilter("[MaHopDong] IS NOT NULL"); b.HasIndex(x => x.MaHopDong).IsUnique().HasFilter("[MaHopDong] IS NOT NULL");
b.HasIndex(x => new { x.Phase, x.IsDeleted }); b.HasIndex(x => new { x.Phase, x.IsDeleted });

View File

@ -19,6 +19,8 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
b.Property(x => x.DiaDiem).HasMaxLength(500); b.Property(x => x.DiaDiem).HasMaxLength(500);
b.Property(x => x.MoTa).HasMaxLength(2000); b.Property(x => x.MoTa).HasMaxLength(2000);
b.Property(x => x.PaymentTerms).HasColumnType("nvarchar(max)"); b.Property(x => x.PaymentTerms).HasColumnType("nvarchar(max)");
b.Property(x => x.BudgetManualName).HasMaxLength(200);
b.Property(x => x.BudgetManualAmount).HasPrecision(18, 2);
b.HasIndex(x => x.MaPhieu).IsUnique().HasFilter("[MaPhieu] IS NOT NULL"); b.HasIndex(x => x.MaPhieu).IsUnique().HasFilter("[MaPhieu] IS NOT NULL");
b.HasIndex(x => new { x.Phase, x.IsDeleted }); b.HasIndex(x => new { x.Phase, x.IsDeleted });

View File

@ -0,0 +1,64 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddManualBudgetFieldsToPeAndContract : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "BudgetManualAmount",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BudgetManualName",
table: "PurchaseEvaluations",
type: "nvarchar(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "BudgetManualAmount",
table: "Contracts",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BudgetManualName",
table: "Contracts",
type: "nvarchar(200)",
maxLength: 200,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BudgetManualAmount",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "BudgetManualName",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "BudgetManualAmount",
table: "Contracts");
migrationBuilder.DropColumn(
name: "BudgetManualName",
table: "Contracts");
}
}
}

View File

@ -467,6 +467,14 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("BudgetId") b.Property<Guid?>("BudgetId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<decimal?>("BudgetManualAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("BudgetManualName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<bool>("BypassProcurementAndCCM") b.Property<bool>("BypassProcurementAndCCM")
.HasColumnType("bit"); .HasColumnType("bit");
@ -2328,6 +2336,14 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("BudgetId") b.Property<Guid?>("BudgetId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<decimal?>("BudgetManualAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("BudgetManualName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid?>("ContractId") b.Property<Guid?>("ContractId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");