[CLAUDE] Phase3: Workflow MVP — 9-phase state machine + code gen + FE Inbox/Detail

Backend Contracts domain (5 entities):
- Contract aggregate: Phase (9 enum), SlaDeadline, MaHopDong, BypassProcurementAndCCM, DraftData, SlaWarningSent
- ContractApproval: FromPhase → ToPhase, ApproverUserId (null = system auto-approve), Decision, Comment
- ContractComment: thread theo Phase current
- ContractAttachment: FileName + StoragePath + Purpose (DraftExport/ScannedSigned/SealedCopy)
- ContractCodeSequence: Prefix PK + LastSeq — atomic gen

EF configs:
- Unique MaHopDong filtered [MaHopDong] IS NOT NULL
- Indexes: Phase+IsDeleted, SupplierId, ProjectId, SlaDeadline, ContractId+ApprovedAt, ContractId+CreatedAt
- Cascade delete Approvals/Comments/Attachments khi Contract xoa
- Query filter IsDeleted
- Migration AddContractsWorkflow (DB 19 tables)

Workflow service:
- IContractWorkflowService.TransitionAsync:
  - Adjacency check qua Transitions Dict<(from,to), roles[]> (12 transitions)
  - Role guard: user phai co role ∈ allowed
  - Admin bypass (role Admin pass moi check)
  - System bypass (userId=null + Decision=AutoApprove → cho SLA job sau nay)
  - Bypass CCM: BypassProcurementAndCCM=true cho phep DangInKy → DangTrinhKy skip phase 6
  - Gen ma HD khi chuyen DangDongDau (idempotent — khong gen lai neu da co)
  - Reset SlaDeadline = UtcNow + PhaseSla
  - Insert ContractApproval row

Code generator (RG-001):
- 7 format theo ContractType: HDTP / HDGK / NCC / HDDV / MB + 2 framework (year prefix)
- BeginTransactionAsync(Serializable) + ContractCodeSequences UPSERT → atomic
- Idempotent: neu MaHopDong da co thi skip

CQRS (8 feature, ContractFeatures.cs):
- CreateContractCommand + Validator + Handler (set SlaDeadline = +7d)
- UpdateContractDraftCommand (chi khi Phase=DangSoanThao)
- TransitionContractCommand (delegate → WorkflowService)
- AddCommentCommand (phase = hien tai)
- ListContractsQuery (PagedResult + filter phase/supplier/project/search)
- GetMyInboxQuery (map Phase → actor roles, filter theo role user)
- GetContractQuery (detail + approvals + comments + attachments + resolve user names)
- DeleteContractCommand (soft, block > DangInKy)

Controller:
- ContractsController 8 endpoint: GET list/inbox/detail, POST create/transition/comment, PUT update, DELETE

Frontend fe-admin (2 page moi):
- types/contracts.ts: ContractPhase const + Label + Color maps + types
- components/PhaseBadge.tsx
- pages/contracts/ContractsListPage.tsx: filter phase + search + click → detail
- pages/contracts/ContractDetailPage.tsx: 2-col layout (info+comments | timeline), action dialog select target phase + comment

Frontend fe-user (4 page moi + 14 file shared):
- cp 14 file shared tu fe-admin (menuKeys, types/*, DataTable, PhaseBadge, Dialog, Textarea, Select, apiError, usePermission, PermissionGuard)
- AuthContext update: load menu tu /menus/me + cache
- Layout: menu fixed 3 muc + user info + roles display
- InboxPage: list HD cho role user xu ly (sort theo SLA)
- ContractCreatePage: form chon loai + template + NCC + du an + gia tri + bypass CDT
- ContractDetailPage: duplicate fe-admin pattern (convention)
- MyContractsPage: list HD cua toi
- App.tsx: 4 route moi

E2E verified:
- Setup Supplier + Project
- POST /contracts → 201 + phase=2
- POST /contracts/{id}/transitions x7 → di het 9 phase
- Final: MaHopDong = "FLOCK 01/HĐGK/SOL&PVL2026/01" dung format RG-001
- Approvals: 7 rows audit day du

Docs:
- .claude/skills/contract-workflow/SKILL.md: placeholder → full spec voi state machine, SLA table, role matrix, 7 code format, code pointers, API, E2E workflow, pitfalls
- docs/changelog/sessions/2026-04-21-1330-phase3-workflow.md: session log
- docs/STATUS.md: Phase 3 MVP done, next Phase 4
- docs/HANDOFF.md: update phase status + file tree + commit log + testing points
- docs/changelog/migration-todos.md: tick Phase 3 MVP items + add iteration 2 list

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 12:26:09 +07:00
parent 5113e4c771
commit 7e957a7654
49 changed files with 4490 additions and 156 deletions

View File

@ -1,34 +1,185 @@
---
name: contract-workflow
description: State machine 9 phase cho hợp đồng TP/NCC/Tổ đội — guard rule, SLA auto-approve, role × phase matrix. Dùng khi debug transition, approve HĐ, xử lý HĐ quá hạn.
description: State machine 9 phase cho hợp đồng TP/NCC/Tổ đội — transition guard, role check, SLA deadline, auto-gen mã HĐ RG-001. Dùng khi debug chuyển phase, 403 forbidden, code sai format, bypass Chủ đầu tư.
when-to-use:
- "transition contract"
- "chuyển phase hợp đồng"
- "HĐ quá hạn auto-approve"
- "role không duyệt được"
- "reject contract về draft"
- "mã HĐ sai format"
- "bypass CCM chủ đầu tư"
---
# Contract Workflow Skill
> **Phase 3 deliverable.** Hiện tại skill này là PLACEHOLDER — sẽ được expand khi implement Phase 3.
> **Status:** Phase 3 IMPLEMENTED (MVP — state transitions + code gen). Còn thiếu: SLA hosted service, email notify, in-app realtime.
## Context
## Domain entities (implemented)
Xem đầy đủ ở [`docs/workflow-contract.md`](../../../docs/workflow-contract.md):
- 9 state: `DangChon``DangSoanThao``DangGopY``DangDamPhan``DangInKy``DangKiemTraCCM``DangTrinhKy``DangDongDau``DaPhatHanh` (+ `TuChoi`)
- SLA mỗi phase: Draft 7d, GópÝ 7d, ĐàmPhán 7d, InKý 1d, CCMCheck 3d, BOD 1d
- Role × Phase matrix (Drafter, TBP/TPB, PD/PM, PRO/EQU/FIN/ACT, CCM, BOD/NĐUQ, HRA)
```
Contract ─────< ContractApproval (lịch sử mỗi transition)
─────< ContractComment (thread góp ý)
─────< ContractAttachment (scan signed/sealed)
## Code pointers (sẽ có sau Phase 3)
ContractCodeSequence (Prefix PK, LastSeq) — gen mã HĐ atomic
```
- `src/Backend/SolutionErp.Domain/Contracts/ContractPhase.cs` (enum)
- `src/Backend/SolutionErp.Domain/Contracts/Contract.cs` (aggregate root)
- `src/Backend/SolutionErp.Application/Contracts/Services/IContractWorkflowService.cs`
- `src/Backend/SolutionErp.Infrastructure/HostedServices/SlaExpiryJob.cs`
## 9 phase state machine
## Common pitfalls (dự kiến — update khi build)
```
DangChon(1) → DangSoanThao(2) → DangGopY(3) → DangDamPhan(4) → DangInKy(5) →
DangKiemTraCCM(6) → DangTrinhKy(7) → DangDongDau(8) → DaPhatHanh(9)
- Không check bypass flag khi HĐ với Chủ đầu tư → sẽ reject oan ở CCM phase
- Gen mã HĐ trước khi BOD approve → có thể waste số thứ tự nếu reject sau đó
- Auto-approve chạy trong transaction dài → lock table → timeout
Alternates:
DangSoanThao → TuChoi(99)
DangGopY → DangSoanThao (revise)
DangKiemTraCCM → DangSoanThao (CCM reject)
DangTrinhKy → DangSoanThao (BOD reject)
Bypass (HĐ Chủ đầu tư, BypassProcurementAndCCM=true):
DangInKy → DangTrinhKy (skip CCM)
```
## SLA mặc định (sau transition, set `Contract.SlaDeadline = UtcNow + sla`)
| Phase | SLA |
|---|---|
| DangSoanThao | 7d |
| DangGopY | 7d |
| DangDamPhan | 7d |
| DangInKy | 1d |
| DangKiemTraCCM | 3d |
| DangTrinhKy | 1d |
| DangDongDau | none |
| DaPhatHanh | none |
## Role × Phase guard matrix
Xem `ContractWorkflowService.Transitions` dictionary. Tóm tắt:
| Phase hiện tại → target | Roles được phép |
|---|---|
| DangSoanThao → DangGopY | Drafter, DeptManager |
| DangSoanThao → TuChoi | Drafter, DeptManager |
| DangGopY → DangDamPhan | Drafter, DeptManager |
| DangGopY → DangSoanThao | ProjectManager, Procurement, CostControl, Finance, Accounting, Equipment |
| DangDamPhan → DangInKy | Drafter, DeptManager, ProjectManager |
| DangInKy → DangKiemTraCCM | Drafter, DeptManager, ProjectManager |
| DangInKy → DangTrinhKy (bypass) | Drafter, DeptManager, ProjectManager (chỉ khi `BypassProcurementAndCCM=true`) |
| DangKiemTraCCM → DangTrinhKy | CostControl |
| DangKiemTraCCM → DangSoanThao | CostControl |
| DangTrinhKy → DangDongDau | Director, AuthorizedSigner |
| DangTrinhKy → DangSoanThao | Director, AuthorizedSigner |
| DangDongDau → DaPhatHanh | HrAdmin |
**Admin bypass:** user có role `Admin` → pass mọi guard. Dùng để test flow nhanh.
## Mã HĐ gen (RG-001)
Xem `ContractCodeGenerator.GenerateAsync()`. Format theo loại HĐ:
| Type | Format |
|---|---|
| HopDongThauPhu | `{ProjectCode}/HĐTP/SOL&{SupplierCode}/{Seq:D2}` |
| HopDongGiaoKhoan | `{ProjectCode}/HĐGK/SOL&{SupplierCode}/{Seq:D2}` |
| HopDongNhaCungCap | `{ProjectCode}/NCC/SOL&{SupplierCode}/{Seq:D2}` |
| HopDongDichVu | `{ProjectCode}/HĐDV/SOL&{SupplierCode}/{Seq:D2}` |
| HopDongMuaBan | `{ProjectCode}/MB/SOL&{SupplierCode}/{Seq:D2}` |
| HopDongNguyenTacNCC | `{Year}/NCC/SOL&{SupplierCode}/{Seq:D2}` ← framework |
| HopDongNguyenTacDichVu | `{Year}/HĐDV/SOL&{SupplierCode}/{Seq:D2}` ← framework |
**Transactional:** `BeginTransactionAsync(IsolationLevel.Serializable)` + `ContractCodeSequences` row UPDATE. Tránh race condition khi 2 HĐ cùng prefix gen song song.
**Gen khi nào:** transition sang `DangDongDau`. Nếu `MaHopDong` đã có (reject rồi approve lại) → giữ nguyên, không gen lại.
## Code pointers
**Backend:**
- `Domain/Contracts/Contract.cs` — aggregate root
- `Domain/Contracts/ContractApproval.cs` — history
- `Domain/Contracts/ContractComment.cs` — thread
- `Domain/Contracts/ContractAttachment.cs` — files
- `Domain/Contracts/ContractCodeSequence.cs` — seq table
- `Application/Contracts/Services/IContractWorkflowService.cs` + `IContractCodeGenerator.cs`
- `Infrastructure/Services/ContractWorkflowService.cs` — state + role guard
- `Infrastructure/Services/ContractCodeGenerator.cs` — transactional gen
- `Application/Contracts/ContractFeatures.cs` — CQRS (Create, Update draft, Transition, AddComment, List, Inbox, GetDetail, Delete)
- `Api/Controllers/ContractsController.cs` — REST endpoints
**Frontend:**
- `fe-admin/src/pages/contracts/ContractsListPage.tsx` — full list admin view
- `fe-admin/src/pages/contracts/ContractDetailPage.tsx` — detail + timeline + action
- `fe-user/src/pages/InboxPage.tsx` — HĐ chờ role tôi xử lý
- `fe-user/src/pages/contracts/ContractCreatePage.tsx` — tạo HĐ draft
- `fe-user/src/pages/contracts/ContractDetailPage.tsx` — duplicate có chủ đích
- `fe-user/src/pages/contracts/MyContractsPage.tsx` — HĐ của tôi
- `fe-admin/src/types/contracts.ts` + `fe-user/src/types/contracts.ts` — type mirror
- `fe-admin/src/components/PhaseBadge.tsx` — badge màu theo phase
## API endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | `/api/contracts` | List với filter phase/supplier/project + paging |
| GET | `/api/contracts/inbox` | HĐ chờ role của user xử lý |
| GET | `/api/contracts/{id}` | Detail + approvals + comments + attachments |
| POST | `/api/contracts` | Tạo draft (Phase = DangSoanThao) |
| PUT | `/api/contracts/{id}` | Update draft (chỉ khi Phase = DangSoanThao) |
| POST | `/api/contracts/{id}/transitions` | Chuyển phase (body: `{targetPhase, decision, comment}`) |
| POST | `/api/contracts/{id}/comments` | Thêm comment vào thread |
| DELETE | `/api/contracts/{id}` | Soft delete (chỉ < DangInKy) |
## Guard Rules đã implement
- **State adjacency:** chỉ cho chuyển giữa các (from, to) đã khai báo trong `Transitions` dict
- **Role check:** role của actor phải allowed roles của transition đó
- **Admin bypass:** role `Admin` pass mọi check
- **System bypass:** `actorUserId == null` + `Decision = AutoApprove` cho phép (dành cho SLA job Phase 3.2)
- **Bypass CCM:** `Contract.BypassProcurementAndCCM=true` cho phép `DangInKy → DangTrinhKy` (skip CCM). Default false phải qua CCM
- **Self-delete:** không cho xóa đã qua `DangInKy`
## Workflow tạo HĐ end-to-end (testable)
```bash
# 1. Setup master data
POST /api/suppliers { code: "PVL", name: "...", type: 1 }
POST /api/projects { code: "FLOCK 01", name: "..." }
# 2. Tạo HĐ
POST /api/contracts { type: 2, supplierId, projectId, giaTri: 150000000, tenHopDong: "..." }
# → Phase = DangSoanThao, SlaDeadline = +7d
# 3. Submit góp ý
POST /api/contracts/{id}/transitions { targetPhase: 3, decision: 1, comment: "..." }
# 4. Chuyển qua các phase (với admin)
4 DangDamPhan → 5 DangInKy → 6 DangKiemTraCCM → 7 DangTrinhKy
# 5. BOD ký → gen mã HĐ
8 DangDongDau
# contract.MaHopDong = "FLOCK 01/HĐGK/SOL&PVL/01"
# 6. HRA đóng dấu + phát hành
9 DaPhatHanh
```
## Common pitfalls (xem gotchas.md)
- **Admin check mọi phase** đôi khi không catch role-scope bug. Test với user không phải Admin.
- ** gen 2 lần** (sau reject rồi approve) generator check `if (MaHopDong is null)` trước khi gen.
- **Race condition gen song song** dùng `IsolationLevel.Serializable`, không skip.
- **SLA Deadline không reset khi reject** `TransitionAsync` luôn reset theo target phase, kể cả reject.
- **Comment phase sai** `AddCommentCommand` luôn lấy phase hiện tại tại thời điểm comment.
- **FE hiển thị next phase button** map `NEXT_PHASES` FE phải match BE `Transitions`. Nếu BE đổi, FE quên update user click 403.
## Phase 3 iteration 2 (còn thiếu)
- [ ] `SlaExpiryJob` BackgroundService auto-approve khi quá hạn (xem `docs/flows/sla-expiry-flow.md`)
- [ ] Warning notification khi còn 20% SLA
- [ ] Email notification (MailKit) khi chuyển phase
- [ ] In-app notification badge SignalR push
- [ ] Upload attachment endpoint + FE (multipart)
- [ ] RowVersion optimistic concurrency (2 user cùng duyệt)
- [ ] ContractClause appendix attach khi export trọn gói
- [ ] Audit log riêng (`AuditLogs` table) ngoài `ContractApprovals`

View File

@ -1,6 +1,6 @@
# HANDOFF — Brief 5 phút cho session tiếp theo
**Last updated:** 2026-04-21 12:00 (cuối Phase 2 MVP)
**Last updated:** 2026-04-21 13:30 (cuối Phase 3 MVP)
## Ở đâu rồi?
@ -9,10 +9,11 @@
| 0 Draft | ✅ Done |
| 1 Alpha Core foundation | ✅ Done |
| 1 Alpha Core đợt 2 (CRUD + Permission) | ✅ Done |
| **2 Form Engine MVP** | ✅ Done |
| 2 Form Engine MVP | ✅ Done |
| 2 Form Engine iteration 2 | 📝 Optional |
| 3 Workflow (9 phase state machine) | 📋 Next |
| 4 Report + Polish | 📋 Queue |
| **3 Workflow MVP (9 phase + code gen)** | ✅ Done |
| 3 Workflow iteration 2 (SLA job + notify + attachment) | 📝 Optional |
| 4 Report + Polish | 📋 Next |
| 5 Production (CI/CD IIS) | 📋 Queue |
## Run nhanh
@ -30,43 +31,48 @@ cd fe-user && npm run dev # → http://localhost:8080
Login: `admin@solutionerp.local` / `Admin@123456`
Điểm cần test ngay:
- `/forms` → render FO-002.05 → download .docx (Phase 2 MVP)
- `/system/permissions` chọn role → tick matrix
- `/master/suppliers|projects|departments` → CRUD
Điểm cần test ngay (Phase 3 MVP):
- **fe-user `/contracts/new`** → tạo HĐ draft (Phase 2 DangSoanThao, SLA +7d)
- **fe-user `/inbox`** → xem HĐ ch role mình xử lý
- **`/contracts/{id}`** (cả 2 FE) → click "Duyệt → tiếp" chạy state machine. Đến phase 8 DangDongDau → xem `MaHopDong` tự gen theo RG-001
- **`/forms`** (admin) → render template .docx với JSON data
- **`/system/permissions`** → ma trận Role × MenuKey
- **`/master/suppliers|projects|departments`** → CRUD
## Cần làm kế tiếp (ưu tiên)
### A. Phase 3Workflow (item lớn, ~3 tuần work)
### A. Phase 4Report + Polish (tuần 10-11)
**Đọc trước:**
1. [`workflow-contract.md`](workflow-contract.md) — spec 9 phase + role matrix
2. [`flows/contract-approval-flow.md`](flows/contract-approval-flow.md) — sequence diagram
3. [`flows/sla-expiry-flow.md`](flows/sla-expiry-flow.md) — BackgroundService auto-approve
4. [`forms-spec.md#RG-001`](forms-spec.md) — format mã HĐ
- Dashboard admin: HĐ theo phase, top NCC, top dự án, tổng giá trị tháng
- Excel export list HĐ (dùng ClosedXML đã có)
- Report quá hạn SLA theo phase/role
- UX polish: skeleton loader, empty state có action, error boundary recovery
- Accessibility: keyboard nav, aria labels
- Index DB hot query (SupplierId, ProjectId, Phase combo)
- User guide docs
**Deliverable chính:**
- Entity: `Contract` (Phase, SlaDeadline, BypassProcurementAndCCM, DraftData) + `ContractApproval` + `ContractComment` + `ContractAttachment`
- `IContractWorkflowService.TransitionAsync()` — state guard (9 phase adjacency) + role guard
- `IContractCodeGenerator` (implement RG-001) với transaction SERIALIZABLE + `ContractCodeSequences` table
- `SlaExpiryJob` BackgroundService — auto-approve HĐ quá hạn mỗi 15 phút
- `INotificationService` — email (MailKit) + in-app
- API `POST /api/contracts/{id}/transitions`
- FE `/inbox` (list HĐ chờ tôi xử lý theo role × phase)
- FE `/contracts/{id}` detail — timeline 9 phase, approval panel, comment thread, attachment upload
### B. Phase 3 iteration 2 (polish workflow)
### B. Phase 2 iteration 2 (nếu user muốn polish Form Engine)
- [ ] `SlaExpiryJob` BackgroundService auto-approve (xem `flows/sla-expiry-flow.md`)
- [ ] Email notification (MailKit) — pick up phase + SMTP config
- [ ] In-app notification (SignalR + badge counter)
- [ ] Upload attachment endpoint + FE multipart (store vào `wwwroot/uploads/contracts/{id}/`)
- [ ] RowVersion optimistic concurrency (2 user race → 409)
- [ ] Render HĐ docx lúc tạo (merge TemplateId + DraftData + ContractClause appendix)
- Convert 3 file `.doc` (retry Word COM với timeout OR LibreOffice)
- Field spec JSON → dynamic form builder
- `{{#loop}}...{{/loop}}` support
- PDF convert
- FE upload template UI
### C. Phase 2 iteration 2 (form engine polish)
### C. Quick wins (không block phase)
- Convert 3 file `.doc` qua Word COM `DisplayAlerts=0` + timeout, hoặc LibreOffice
- Field spec JSON per template + dynamic form builder FE
- `{{#loop}}...{{/loop}}` block support cho table lặp
- PDF convert via LibreOffice headless
- Admin upload template UI (multipart)
- FE Users management + Roles CRUD (test permission với non-admin role)
- fe-user sync menu động (đang hardcode)
### D. Quick wins (không block phase)
- FE Users management + Roles CRUD (test permission với non-admin user thật)
- Filter Inbox theo phase FE
- Refresh token auto (FE axios interceptor retry 401)
## Lưu ý kỹ thuật quan trọng
@ -85,43 +91,50 @@ SOLUTION_ERP/
├── src/Backend/ (Clean Arch, 4 project, .NET 10)
│ ├── SolutionErp.Domain/
│ │ ├── Common/ BaseEntity, AuditableEntity
│ │ ├── Contracts/ ContractType, ContractPhase, ApprovalDecision
│ │ ├── Contracts/ ContractType, ContractPhase, ApprovalDecision + **Contract, ContractApproval, ContractComment, ContractAttachment, ContractCodeSequence** ← Phase 3
│ │ ├── Forms/ ContractTemplate, ContractClause ← Phase 2
│ │ ├── Identity/ User, Role, MenuItem, Permission, AppRoles, MenuKeys
│ │ └── Master/ Supplier, Project, Department, SupplierType
│ ├── SolutionErp.Application/
│ │ ├── Auth/ Login, Refresh, Me
│ │ ├── Common/ Exceptions, Behaviors, Interfaces, Models
│ │ ├── Contracts/ **ContractFeatures (8 CQRS), IContractWorkflowService, IContractCodeGenerator, DTOs** ← Phase 3
│ │ ├── Forms/ FormFeatures (List/Get/Render) ← Phase 2
│ │ ├── Master/ Suppliers, Projects, Departments CQRS
│ │ └── Permissions/ GetMyMenuTree, matrix upsert
│ ├── SolutionErp.Infrastructure/
│ │ ├── Forms/ DocxRenderer, XlsxRenderer, FormRenderer ← Phase 2
│ │ ├── Identity/ JwtSettings, JwtTokenService
│ │ ├── Persistence/ DbContext, DbInitializer, Interceptors, Migrations (4)
│ │ └── Services/ DateTimeService
│ │ ├── Persistence/ DbContext, DbInitializer, Interceptors, Migrations (**5** now)
│ │ └── Services/ DateTimeService, **ContractWorkflowService, ContractCodeGenerator** ← Phase 3
│ └── SolutionErp.Api/
│ ├── Authorization/ MenuPermissionHandler + Requirement
│ ├── Controllers/ Auth, Suppliers, Projects, Departments, Menus, Roles, Permissions, Forms
│ ├── Controllers/ Auth, Suppliers, Projects, Departments, Menus, Roles, Permissions, Forms, **Contracts** ← Phase 3
│ ├── Middleware/ GlobalExceptionMiddleware
│ ├── Services/ CurrentUserService, WebHostEnvironmentLocator
│ └── wwwroot/templates/ 5 file .docx/.xlsx ← Phase 2
├── fe-admin/ (7 page)
├── fe-admin/ (9 page)
│ └── src/pages/
│ ├── LoginPage
│ ├── DashboardPage
│ ├── master/SuppliersPage, ProjectsPage, DepartmentsPage
│ ├── system/PermissionsPage
── forms/FormsPage ← Phase 2
├── fe-user/ (2 page — Login + Inbox placeholder)
├── docs/ (24 file — STATUS, PROJECT-MAP, workflow, forms-spec, database-guide, 6 flow, gotchas, 4 session log, 1 handoff)
└── .claude/skills/ (3 skill — contract-workflow placeholder, form-engine + permission-matrix full spec)
── forms/FormsPage ← Phase 2
│ └── contracts/ContractsListPage, ContractDetailPage ← Phase 3
├── fe-user/ (5 page)
│ └── src/pages/
│ ├── LoginPage
│ ├── InboxPage ← Phase 3
│ └── contracts/ContractCreatePage, ContractDetailPage, MyContractsPage ← Phase 3
├── docs/ (26 file — STATUS, PROJECT-MAP, workflow, forms-spec, database-guide, 6 flow, gotchas, HANDOFF, 5 session log)
└── .claude/skills/ (3 skill — all full spec: contract-workflow, form-engine, permission-matrix)
```
## Git state
```
49a5f57..(sẽ là commit 5) — Phase 2 MVP
(sẽ là commit 6) — Phase 3 Workflow MVP
5113e4c — Phase 2 Form Engine MVP
54d6c9b — Phase 1.2 CRUD + Permission
49a5f57 — Docs database-guide + flows
702411f — Phase 1 foundation
@ -146,10 +159,14 @@ admin@solutionerp.local / Admin@123456
**Tốt:**
- Build pass 100% cả BE + FE
- E2E test: login + CRUD + render template đều pass
- Docs đầy đủ: 24 file, session log mỗi chunk, gotchas library tích lũy
- E2E test full 9-phase workflow end-to-end — mã HĐ gen đúng format RG-001
- Docs đầy đủ: 26 file, session log mỗi chunk, gotchas tích lũy 17 pitfall
- Cả 2 FE đều có Contract detail page + timeline + comment thread + state machine action
**Rủi ro:**
- fe-user còn thô — Phase 3 phải build inbox
**Rủi ro còn:**
- SLA chỉ set deadline — không có job auto-approve (Phase 3.2)
- Không có notification (email + in-app) — user phải F5 inbox
- Form render chỉ MVP — loop table + PDF chưa có
- Permission matrix chưa test thực với non-admin user
- 3 file .doc chưa convert (carryover Phase 2)
- Không có upload attachment endpoint (chỉ có entity + DTO)

View File

@ -2,78 +2,84 @@
> **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-04-21 12:00
**Last updated:** 2026-04-21 13:30
## 📍 Phase hiện tại: **Phase 2 Form Engine (MVP xong)** — sẵn sàng Phase 3 Workflow
## 📍 Phase hiện tại: **Phase 3 Workflow (MVP xong)** — sẵn sàng Phase 4 Report hoặc polish Phase 3 iteration 2
## 🔥 In Progress
_(không có — tạm nghỉ chờ user approve move on)_
_(không có)_
## ✅ Recently Done (newest on top)
| Ngày | Ai | Task | Commit |
|---|---|---|---|
| 2026-04-21 | Claude | **Phase 2 Form Engine MVP** BE: ContractTemplate/ContractClause entities + OpenXml + ClosedXML renderer (placeholder `{{field}}`) + FormsController + seed 8 template. FE: FormsPage list + render dialog. `/api/forms/templates/{id}/render` trả file .docx/.xlsx 482KB OK | (sắp commit) |
| 2026-04-21 | Claude | **Docs:** `gotchas.md` (17 pitfalls) + update 2 skill (form-engine, permission-matrix) từ placeholder → full spec | (sắp commit) |
| 2026-04-21 | Claude | **Phase 1 đợt 2** BE CRUD Supplier/Project/Department + Permission Matrix + FE 4 page | `54d6c9b` |
| 2026-04-21 | Claude | **Docs addition**`database-guide.md` + `flows/` 6 doc | `49a5f57` |
| 2026-04-21 | Claude | **Phase 1 foundation** BE Clean Arch + Identity + JWT + FE 2 app + login E2E | `702411f` |
| 2026-04-21 | Claude | **Phase 0** — scaffold + parse FORM/QUY_TRINH + docs + git init | `25dad7f` |
| 2026-04-21 | Claude | **Phase 3 Workflow MVP** — Contract+Approval+Comment+Attachment+CodeSequence entities + IContractWorkflowService (9 phase adjacency + role guard + bypass CĐT) + IContractCodeGenerator (RG-001 transactional) + CQRS 8 command/query + ContractsController. FE: fe-admin ContractsList + ContractDetail, fe-user Inbox+Create+Detail+MyContracts. **E2E 9 phase end-to-end pass với mã HĐ `FLOCK 01/HĐGK/SOL&PVL2026/01`** | (sắp commit) |
| 2026-04-21 | Claude | **Phase 2 Form Engine MVP** — OpenXml + ClosedXML renderer + seed 8 template + FE FormsPage | `5113e4c` |
| 2026-04-21 | Claude | **Phase 1.2** — CRUD Master + Permission Matrix + FE 4 page | `54d6c9b` |
| 2026-04-21 | Claude | **Docs + flows** | `49a5f57` |
| 2026-04-21 | Claude | **Phase 1 foundation** — Clean Arch + Identity + JWT + 2 FE + login E2E | `702411f` |
| 2026-04-21 | Claude | **Phase 0** — scaffold + docs | `25dad7f` |
Session logs:
- [`changelog/sessions/2026-04-21-1045-phase0-scaffold.md`](changelog/sessions/2026-04-21-1045-phase0-scaffold.md)
- [`changelog/sessions/2026-04-21-1100-phase1-foundation.md`](changelog/sessions/2026-04-21-1100-phase1-foundation.md)
- [`changelog/sessions/2026-04-21-1130-phase1-cruds-permission.md`](changelog/sessions/2026-04-21-1130-phase1-cruds-permission.md)
- [`changelog/sessions/2026-04-21-1200-phase2-form-engine.md`](changelog/sessions/2026-04-21-1200-phase2-form-engine.md)
Session logs: [Phase 0](changelog/sessions/2026-04-21-1045-phase0-scaffold.md) · [Phase 1f](changelog/sessions/2026-04-21-1100-phase1-foundation.md) · [Phase 1.2](changelog/sessions/2026-04-21-1130-phase1-cruds-permission.md) · [Phase 2](changelog/sessions/2026-04-21-1200-phase2-form-engine.md) · [Phase 3](changelog/sessions/2026-04-21-1330-phase3-workflow.md)
Gotchas library: [`gotchas.md`](gotchas.md) — 17 pitfalls đã gặp + cách xử lý.
Gotchas: [`gotchas.md`](gotchas.md) · Handoff: [`HANDOFF.md`](HANDOFF.md)
## 🎯 Next up
### Phase 2 iteration 2 (optional — enhance)
### Phase 3 iteration 2 (polish — optional)
- [ ] Convert 3 file `.doc` (retry Word COM với timeout, hoặc LibreOffice headless)
- [ ] Field spec JSON mỗi template + dynamic form builder FE
- [ ] Support `{{#loop}}...{{/loop}}` cho table lặp
- [ ] PDF convert via LibreOffice
- [ ] Admin upload template UI (POST multipart)
- [ ] `SlaExpiryJob` BackgroundService auto-approve quá hạn
- [ ] Email notification (MailKit) khi chuyển phase
- [ ] In-app notification (SignalR + badge)
- [ ] Upload attachment endpoint + FE multipart
- [ ] RowVersion optimistic concurrency
- [ ] Render HĐ docx khi tạo (merge ContractClause appendix)
### Phase 3Workflow (sắp tới, item lớn)
### Phase 4Report + Polish (tuần 10-11)
Xem [`docs/flows/contract-approval-flow.md`](flows/contract-approval-flow.md) + [`docs/workflow-contract.md`](workflow-contract.md).
- [ ] Dashboard admin: HĐ theo phase / top NCC / top dự án / tổng giá trị tháng
- [ ] Excel export list HĐ (EPPlus/ClosedXML)
- [ ] Report quá hạn SLA theo phase/role
- [ ] UX polish: skeleton, empty state, error boundary
- [ ] Accessibility pass
- [ ] Index DB hot query
- [ ] User guide docs
- [ ] UAT với data thật
Summary:
- [ ] Entity: Contract + ContractApproval + ContractComment + ContractAttachment
- [ ] `IContractWorkflowService` với state guard + role guard
- [ ] `IContractCodeGenerator` RG-001 + transaction SERIALIZABLE
- [ ] `SlaExpiryJob` BackgroundService
- [ ] Email + in-app notification service
- [ ] API `POST /api/contracts/{id}/transitions`
- [ ] FE Inbox + Contract detail page + timeline UI
### Phase 5 — Production (tuần 12-13)
### Optional (không block Phase 3)
- [ ] CI/CD Gitea Actions → IIS deploy
- [ ] HTTPS cert + appsettings Production secrets
- [ ] Rate limiting + Security audit
- [ ] Backup/restore runbook
- [ ] FE Users management + Roles CRUD (cho test permission với non-admin role)
- [ ] fe-user sync menu động (đang hardcode)
### Quick wins (không block)
- [ ] FE Users management + Roles CRUD
- [ ] Filter Inbox theo phase FE
- [ ] Refresh token auto (FE axios interceptor)
## 📊 Thông số cumulative
| | Phase 0 | +Phase 1f | +Phase 1.2 | +Docs | +Phase 2 MVP |
| | Phase 0 | 1f | 1.2 | 2 | **Phase 3 MVP** |
|---|---:|---:|---:|---:|---:|
| BE LOC | 0 | ~400 | ~1500 | — | ~1900 |
| DB tables | 0 | 7 | 12 | | 14 |
| API endpoints | 0 | 4 | ~20 | — | ~23 |
| Migrations | 0 | 1 | 3 | | 4 |
| FE pages | 0 | 2 | 6 | | 7 |
| Docs files | 10 | 13 | 14 | 21 | 24 |
| Commits | 1 | 2 | 3 | | 5 (sắp) |
| BE LOC | 0 | ~400 | ~1500 | ~1900 | **~2700** |
| DB tables | 0 | 7 | 12 | 14 | **19** |
| API endpoints | 0 | 4 | ~20 | ~23 | **~31** |
| Migrations | 0 | 1 | 3 | 4 | **5** |
| FE pages | 0 | 2 | 6 | 7 | **14** (9 admin + 5 user) |
| Docs | 10 | 13 | 14 | 24 | **26** |
| Commits | 1 | 2 | 3 | 5 | **6** (sắp) |
## 🚨 Blockers / risks
-**Gitea remote URL**user sẽ cấp sau
- ⚠️ **3 file .doc** chưa convert (IsActive=false) — retry Word COM với timeout/`DisplayAlerts=0` hoặc LibreOffice
- ⚠️ **fe-user** chưa đồng bộ menu động (chỉ fe-admin đã chuyển) — quick fix lúc Phase 3
-**Gitea remote URL**vẫn chờ
- ⚠️ **SLA hiện chỉ set deadline** — không có job auto-approve (Phase 3.2)
- ⚠️ **Không có notification** (email/in-app) — user phải F5 inbox manual
- ⚠️ **Không có RowVersion** — 2 user cùng transition race → last-write-wins
- ⚠️ **3 file .doc chưa convert** (Phase 2 carryover)
- ⚠️ **Permission chưa test với non-admin user** — tất cả E2E đều dùng admin
## Credentials + URLs

View File

@ -122,19 +122,37 @@
## Phase 3 — Workflow State Machine (T7-9)
- [ ] `Domain/Entities/ContractApproval` + `ContractComment` + `ContractAttachment`
- [ ] `Domain/Entities/Contract` update: thêm `Phase`, `SlaDeadline`, `BypassProcurementAndCCM`
- [ ] `Domain/Services/IContractWorkflowService.TransitionAsync(...)` — state guard + role guard + side effects
- [ ] `Infrastructure/Services/ContractCodeGenerator` (implement RG-001) với locking cho seq
- [ ] `Infrastructure/HostedServices/SlaExpiryJob` — check mỗi 15min, auto-approve quá hạn
- [ ] `Infrastructure/Services/NotificationService` — email (MailKit) + in-app (SignalR optional)
- [ ] MediatR behavior: `AuditBehavior` — log mọi command
- [ ] API: `POST /api/contracts/{id}/transitions` body: `{targetPhase, comment}`
- [ ] FE user Inbox: list "HĐ chờ tôi xử lý" (query by current phase + user role)
- [ ] FE Contract detail page: timeline 9 phase, approval panel, comment thread
- [ ] Upload attachment (scan có chữ ký đối tác)
- [ ] Notification UI: badge count, dropdown, click → detail
- [ ] E2E test: happy path end-to-end 1 HĐ qua 9 phase
### MVP xong (iteration 1)
- [x] `Domain/Contracts/Contract` (Phase, SlaDeadline, BypassProcurementAndCCM, MaHopDong, DraftData, SlaWarningSent)
- [x] `Domain/Contracts/ContractApproval` (FromPhase, ToPhase, ApproverUserId, Decision, Comment)
- [x] `Domain/Contracts/ContractComment` + `ContractAttachment` (+ AttachmentPurpose enum)
- [x] `Domain/Contracts/ContractCodeSequence` (Prefix PK, LastSeq)
- [x] EF config + unique MaHopDong filtered + indexes Phase/Supplier/Project/SlaDeadline + cascade delete
- [x] DbSets (5) + `IApplicationDbContext` update
- [x] Migration `AddContractsWorkflow`
- [x] `Application/Contracts/Services/IContractWorkflowService` + `IContractCodeGenerator`
- [x] `Infrastructure/Services/ContractWorkflowService` — adjacency 9 phase + role guard + Admin bypass + system actor + bypass CCM (Chủ đầu tư)
- [x] `Infrastructure/Services/ContractCodeGenerator` — 7 format RG-001 + transaction SERIALIZABLE
- [x] CQRS: Create/UpdateDraft/Transition/AddComment/List/Inbox/GetDetail/Delete (8 feature)
- [x] `Api/Controllers/ContractsController` — 8 endpoint REST
- [x] FE admin: ContractsListPage + ContractDetailPage (timeline + action dialog)
- [x] FE user: InboxPage + ContractCreatePage + ContractDetailPage + MyContractsPage
- [x] PhaseBadge component + color map
- [x] E2E verified: tạo HĐ → chạy 9 phase → gen mã `FLOCK 01/HĐGK/SOL&PVL2026/01`
### Iteration 2 (polish — optional)
- [ ] `Infrastructure/HostedServices/SlaExpiryJob` — check mỗi 15min, auto-approve quá hạn với Decision=AutoApprove
- [ ] Warning notification khi còn 20% SLA
- [ ] `Infrastructure/Services/NotificationService` — email (MailKit) + in-app
- [ ] SignalR hub cho real-time notification badge
- [ ] MediatR `AuditBehavior` — log mọi command (ngoài ContractApprovals)
- [ ] Upload attachment endpoint (multipart) + FE upload UI (`wwwroot/uploads/contracts/{id}/`)
- [ ] RowVersion optimistic concurrency (2 user race → 409)
- [ ] Render HĐ docx lúc tạo (merge TemplateId + DraftData + ContractClause appendix)
- [ ] E2E test với non-admin user (Drafter/CCM/BOD role)
- [ ] Filter Inbox theo phase ở FE
- [ ] E2E test: reject → quay về DangSoanThao
- [ ] E2E test: SLA expired → auto-approve + log

View File

@ -0,0 +1,148 @@
# Session 2026-04-21 13:30 — Phase 3 Workflow MVP
**Dev:** Claude (Opus 4.7)
**Duration:** ~1h15m
**Base commit:** `5113e4c`
## Làm được
### Chunk G — Domain + EF + Migration
- Domain/Contracts: **Contract** (aggregate, 15 fields) + **ContractApproval** (history) + **ContractComment** (thread) + **ContractAttachment** (files với `AttachmentPurpose` enum) + **ContractCodeSequence** (Prefix PK + LastSeq)
- EF configs với:
- Unique MaHopDong filtered `WHERE [MaHopDong] IS NOT NULL`
- Indexes: (Phase, IsDeleted), SupplierId, ProjectId, SlaDeadline
- Cascade delete cho Approvals/Comments/Attachments khi Contract xóa
- Query filter IsDeleted
- DbSets + `IApplicationDbContext` update (5 DbSet mới)
- Migration `AddContractsWorkflow`
### Chunk H — Workflow service + CodeGenerator + CQRS + Controller
**Services:**
- `IContractWorkflowService.TransitionAsync(contract, targetPhase, userId, roles, decision, comment)`:
- Adjacency check qua `Transitions` Dictionary<(from,to), roles[]>
- Role check (Admin bypass)
- System actor bypass (cho SLA job Phase 3.2)
- Bypass rule Chủ đầu tư (DangInKy → DangTrinhKy skip CCM)
- Gen mã HĐ khi DangDongDau (nếu chưa có)
- Reset SlaDeadline theo target phase SLA
- Insert ContractApproval row
- `IContractCodeGenerator.GenerateAsync()`:
- 7 format theo ContractType (HĐTP/HĐGK/NCC/HĐDV/MB + 2 framework)
- `BeginTransactionAsync(IsolationLevel.Serializable)` + `ContractCodeSequences` UPSERT → atomic
- Register scoped trong Infrastructure DI
**CQRS (ContractFeatures.cs):**
- `CreateContractCommand` + Validator + Handler (tạo draft, SlaDeadline = UtcNow + 7d)
- `UpdateContractDraftCommand` (chỉ khi Phase = DangSoanThao)
- `TransitionContractCommand` (delegate → WorkflowService)
- `AddCommentCommand` (phase = hiện tại)
- `ListContractsQuery` (PagedResult với filter phase/supplier/project + search)
- `GetMyInboxQuery` (map Phase → roles, return HĐ phù hợp role user)
- `GetContractQuery` (detail + approvals + comments + attachments, resolve user names)
- `DeleteContractCommand` (soft, block > DangInKy)
**Controller:**
- `ContractsController` với 8 endpoint: GET list/inbox/detail, POST create/transition/comment, PUT update, DELETE
- Body DTOs inline: `TransitionContractBody`, `AddCommentBody`
### Chunk I — Frontend 2 app
**fe-admin (2 page mới):**
- `types/contracts.ts` — ContractPhase/Decision const-object + Label + Color maps + types
- `components/PhaseBadge.tsx` — badge màu theo phase
- `pages/contracts/ContractsListPage.tsx` — DataTable full filter + click row → detail
- `pages/contracts/ContractDetailPage.tsx` — 2-col layout: info + comments + timeline + action dialog (approve/reject với select target phase + comment)
**fe-user (4 page mới + port shared):**
- Copy 14 file shared từ fe-admin (menuKeys, types/*, DataTable, PhaseBadge, Dialog, Textarea, Select, apiError, usePermission, PermissionGuard)
- `contexts/AuthContext.tsx` update — load menu from `/menus/me` + localStorage cache
- `components/Layout.tsx` — menu fixed 3 mục (Inbox / Tạo mới / HĐ của tôi) + user info + role display
- `pages/InboxPage.tsx` — list `/api/contracts/inbox` (HĐ chờ role của tôi xử lý, sort theo SLA)
- `pages/contracts/ContractCreatePage.tsx` — form chọn loại HĐ + template + NCC + dự án + giá trị + bypass CĐT
- `pages/contracts/ContractDetailPage.tsx` — duplicate fe-admin pattern (convention)
- `pages/contracts/MyContractsPage.tsx` — HĐ của tôi
- `App.tsx` — 4 route mới
## E2E verified
```bash
# Setup
POST /api/suppliers { code: "PVL2026", type: 1 }201
POST /api/projects { code: "FLOCK 01" }201
# Tạo HĐ
POST /api/contracts { type: 2, supplierId, projectId, giaTri: 150000000 }
201 contractId e20083d5
phase=2 (DangSoanThao), slaDeadline=+7d
# Inbox
GET /api/contracts/inbox → 1 contract
# Full workflow 9 phase (admin bypass)
POST /transitions {targetPhase: 3}204
POST /transitions {targetPhase: 4}204
POST /transitions {targetPhase: 5}204
POST /transitions {targetPhase: 6}204
POST /transitions {targetPhase: 7}204
POST /transitions {targetPhase: 8}204 ← GEN MÃ HĐ
POST /transitions {targetPhase: 9}204
# Final
GET /api/contracts/{id}
→ phase: 9 (DaPhatHanh)
→ maHopDong: "FLOCK 01/HĐGK/SOL&PVL2026/01" ✅ ĐÚNG FORMAT RG-001
7 approvals với đủ from/to/decision/comment
```
TS check fe-admin + fe-user pass.
## Bug gặp + fix
| Bug | Fix |
|---|---|
| Edit tool "File not read" sau system-reminder | Re-read file rồi Write full |
| `Python f-string backslash` trong curl test script | Bỏ escape trong f-string |
## Docs updates
- `.claude/skills/contract-workflow/SKILL.md` — full spec từ placeholder: state machine, role matrix, SLA, RG-001 format 7 loại, code pointers, API, workflow E2E, pitfalls
- Session log này (session #4 trong changelog/sessions)
## Handoff cho session tiếp theo
### Phase 3 iteration 2 (optional — polish)
- [ ] `SlaExpiryJob` BackgroundService (hosted service, 15min interval, auto-approve quá hạn với `Decision=AutoApprove`) — spec ở `docs/flows/sla-expiry-flow.md`
- [ ] Email notification (MailKit) khi chuyển phase
- [ ] In-app notification (SignalR + badge counter)
- [ ] Upload attachment endpoint + FE (multipart, store vào `wwwroot/uploads/contracts/{id}/`)
- [ ] RowVersion optimistic concurrency (2 user cùng duyệt → 409)
- [ ] Render HĐ ra .docx lúc tạo (link với TemplateId + DraftData + merge ContractClause appendix)
- [ ] E2E test permission matrix: tạo user role Drafter thật → verify chỉ thấy nút approve khi ở đúng phase
### Phase 4 — Report + Polish
Xem `docs/changelog/migration-todos.md` section Phase 4.
### Quick wins
- FE Users management (tạo user, gán role) — để test workflow với multi-user
- Filter Inbox theo phase (dropdown FE)
- Export HĐ list ra Excel
### Blocker
-**Gitea remote URL** (vẫn chờ)
## Thông số cumulative
| | Sau Phase 2 | Sau Phase 3 MVP |
|---|---:|---:|
| BE LOC | ~1900 | ~2700 |
| DB tables | 14 | 19 (+Contracts, ContractApprovals, Comments, Attachments, CodeSequences) |
| API endpoints | ~23 | ~31 (+8 contract) |
| Migrations | 4 | 5 |
| FE pages | 7 (fe-admin) + 2 (fe-user) | 9 + 5 |
| Commits | 5 | 6 (sắp) |

View File

@ -10,6 +10,8 @@ import { ProjectsPage } from '@/pages/master/ProjectsPage'
import { DepartmentsPage } from '@/pages/master/DepartmentsPage'
import { PermissionsPage } from '@/pages/system/PermissionsPage'
import { FormsPage } from '@/pages/forms/FormsPage'
import { ContractsListPage } from '@/pages/contracts/ContractsListPage'
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
function App() {
return (
@ -30,6 +32,8 @@ function App() {
<Route path="/master/departments" element={<DepartmentsPage />} />
<Route path="/system/permissions" element={<PermissionsPage />} />
<Route path="/forms" element={<FormsPage />} />
<Route path="/contracts" element={<ContractsListPage />} />
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route
path="*"

View File

@ -0,0 +1,10 @@
import { cn } from '@/lib/cn'
import { ContractPhaseColor, ContractPhaseLabel } from '@/types/contracts'
export function PhaseBadge({ phase, className }: { phase: number; className?: string }) {
return (
<span className={cn('inline-flex rounded-full px-2 py-0.5 text-xs font-medium', ContractPhaseColor[phase], className)}>
{ContractPhaseLabel[phase]}
</span>
)
}

View File

@ -0,0 +1,250 @@
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import { ArrowRight, CheckCircle2, MessageSquare, Clock, ArrowLeft, XCircle } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { PhaseBadge } from '@/components/PhaseBadge'
import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { Dialog } from '@/components/ui/Dialog'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import {
ApprovalDecision,
ContractPhase,
ContractPhaseLabel,
type ContractDetail,
} from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
// Các phase có thể chuyển đến từ phase hiện tại (match adjacency BE)
const NEXT_PHASES: Record<number, number[]> = {
[ContractPhase.DangSoanThao]: [ContractPhase.DangGopY, ContractPhase.TuChoi],
[ContractPhase.DangGopY]: [ContractPhase.DangDamPhan, ContractPhase.DangSoanThao],
[ContractPhase.DangDamPhan]: [ContractPhase.DangInKy],
[ContractPhase.DangInKy]: [ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy],
[ContractPhase.DangKiemTraCCM]: [ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao],
[ContractPhase.DangTrinhKy]: [ContractPhase.DangDongDau, ContractPhase.DangSoanThao],
[ContractPhase.DangDongDau]: [ContractPhase.DaPhatHanh],
}
export function ContractDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const qc = useQueryClient()
const [actionOpen, setActionOpen] = useState(false)
const [targetPhase, setTargetPhase] = useState<number>(0)
const [decision, setDecision] = useState<number>(ApprovalDecision.Approve)
const [comment, setComment] = useState('')
const [commentInput, setCommentInput] = useState('')
const detail = useQuery({
queryKey: ['contract', id],
queryFn: async () => (await api.get<ContractDetail>(`/contracts/${id}`)).data,
enabled: !!id,
})
const transition = useMutation({
mutationFn: async () => {
await api.post(`/contracts/${id}/transitions`, { targetPhase, decision, comment: comment || null })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract', id] })
qc.invalidateQueries({ queryKey: ['contracts'] })
qc.invalidateQueries({ queryKey: ['inbox'] })
toast.success('Đã chuyển phase')
setActionOpen(false)
setComment('')
},
onError: err => toast.error(getErrorMessage(err)),
})
const addComment = useMutation({
mutationFn: async (content: string) => {
await api.post(`/contracts/${id}/comments`, { content })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract', id] })
setCommentInput('')
toast.success('Đã gửi comment')
},
onError: err => toast.error(getErrorMessage(err)),
})
if (detail.isLoading) return <div className="p-8 text-slate-500">Đang tải</div>
if (!detail.data) return <div className="p-8 text-slate-500">Không tìm thấy .</div>
const c = detail.data
const availableTargets = NEXT_PHASES[c.phase] ?? []
function openAction(decisionType: number) {
const targets = NEXT_PHASES[c.phase] ?? []
// Default: approve → first target; reject → prev (find DangSoanThao)
const defaultTarget = decisionType === ApprovalDecision.Reject
? targets.find(t => t === ContractPhase.DangSoanThao) ?? targets[0]
: targets[0]
setTargetPhase(defaultTarget)
setDecision(decisionType)
setActionOpen(true)
}
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-3">
<button onClick={() => navigate(-1)} className="text-slate-400 hover:text-slate-700">
<ArrowLeft className="h-5 w-5" />
</button>
{c.tenHopDong ?? 'HĐ chưa đặt tên'}
</span>
}
description={
<span className="flex items-center gap-3">
<span className="font-mono text-xs">{c.maHopDong ?? '(chưa có mã)'}</span>
<PhaseBadge phase={c.phase} />
</span>
}
actions={
<div className="flex gap-2">
{availableTargets.length > 0 && (
<>
<Button variant="outline" onClick={() => openAction(ApprovalDecision.Reject)}>
<XCircle className="h-4 w-4" />
Yêu cầu sửa
</Button>
<Button onClick={() => openAction(ApprovalDecision.Approve)}>
<CheckCircle2 className="h-4 w-4" />
Duyệt phase tiếp
</Button>
</>
)}
</div>
}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-2">
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-3 text-sm font-semibold text-slate-700">Thông tin </h2>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div><dt className="text-slate-500">Loại</dt><dd>{ContractTypeLabel[c.type] ?? '—'}</dd></div>
<div><dt className="text-slate-500">Giá trị</dt><dd>{fmtMoney(c.giaTri)}</dd></div>
<div><dt className="text-slate-500">NCC</dt><dd>{c.supplierName}</dd></div>
<div><dt className="text-slate-500">Dự án</dt><dd>{c.projectName}</dd></div>
<div><dt className="text-slate-500">Phòng ban</dt><dd>{c.departmentName ?? '—'}</dd></div>
<div><dt className="text-slate-500">Người soạn</dt><dd>{c.drafterName ?? '—'}</dd></div>
<div><dt className="text-slate-500">SLA</dt><dd>{c.slaDeadline ? fmt(c.slaDeadline) : '—'}</dd></div>
<div><dt className="text-slate-500">Bypass CCM</dt><dd>{c.bypassProcurementAndCCM ? 'Có (HĐ Chủ đầu tư)' : 'Không'}</dd></div>
</dl>
{c.noiDung && (
<div className="mt-3">
<dt className="text-sm text-slate-500">Nội dung</dt>
<dd className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{c.noiDung}</dd>
</div>
)}
</section>
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<MessageSquare className="h-4 w-4" />
Góp ý ({c.comments.length})
</h2>
<div className="space-y-3">
{c.comments.length === 0 && <div className="text-sm text-slate-400">Chưa góp ý.</div>}
{c.comments.map(cm => (
<div key={cm.id} className="rounded-md border border-slate-100 p-3">
<div className="flex items-center justify-between text-xs text-slate-500">
<span className="font-medium text-slate-700">{cm.userName}</span>
<span>{fmt(cm.createdAt)} · {ContractPhaseLabel[cm.phase]}</span>
</div>
<div className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{cm.content}</div>
</div>
))}
</div>
<form
className="mt-4 flex gap-2"
onSubmit={(e: FormEvent) => {
e.preventDefault()
if (!commentInput.trim()) return
addComment.mutate(commentInput.trim())
}}
>
<Textarea
rows={2}
placeholder="Thêm góp ý…"
value={commentInput}
onChange={e => setCommentInput(e.target.value)}
/>
<Button type="submit" disabled={addComment.isPending || !commentInput.trim()}>
Gửi
</Button>
</form>
</section>
</div>
<aside>
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<Clock className="h-4 w-4" />
Lịch sử duyệt ({c.approvals.length})
</h2>
<ol className="space-y-3">
{c.approvals.length === 0 && <li className="text-sm text-slate-400">Chưa lịch sử.</li>}
{c.approvals.map(a => (
<li key={a.id} className="flex gap-3">
<div className="mt-1 h-2 w-2 rounded-full bg-brand-500" />
<div className="flex-1 space-y-0.5 text-sm">
<div className="flex items-center gap-1">
<PhaseBadge phase={a.fromPhase} className="text-[10px]" />
<ArrowRight className="h-3 w-3 text-slate-400" />
<PhaseBadge phase={a.toPhase} className="text-[10px]" />
</div>
<div className="text-slate-700">{a.approverName ?? 'Hệ thống'}</div>
<div className="text-xs text-slate-500">{fmt(a.approvedAt)}</div>
{a.comment && <div className="mt-1 rounded bg-slate-50 px-2 py-1 text-xs text-slate-600">{a.comment}</div>}
</div>
</li>
))}
</ol>
</section>
</aside>
</div>
<Dialog
open={actionOpen}
onClose={() => setActionOpen(false)}
title={decision === ApprovalDecision.Reject ? 'Yêu cầu sửa' : 'Chuyển phase tiếp'}
footer={
<>
<Button variant="outline" onClick={() => setActionOpen(false)}>Hủy</Button>
<Button onClick={() => transition.mutate()} disabled={transition.isPending || !targetPhase}>
{transition.isPending ? 'Đang xử lý…' : 'Xác nhận'}
</Button>
</>
}
>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700">Chuyển đến phase</label>
<Select value={targetPhase} onChange={e => setTargetPhase(Number(e.target.value))}>
{availableTargets.map(p => (
<option key={p} value={p}>{ContractPhaseLabel[p]}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700">Ghi chú (optional)</label>
<Textarea rows={3} value={comment} onChange={e => setComment(e.target.value)} />
</div>
</div>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,81 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, Pagination, type Column } from '@/components/DataTable'
import { PhaseBadge } from '@/components/PhaseBadge'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api'
import type { Paged } from '@/types/master'
import { ContractPhase, ContractPhaseLabel, type ContractListItem } from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
const fmtSla = (s: string | null) => {
if (!s) return '—'
const ms = new Date(s).getTime() - Date.now()
const days = Math.floor(ms / (1000 * 60 * 60 * 24))
const hours = Math.floor((ms % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
if (ms < 0) return <span className="text-red-600">Quá hạn</span>
if (days > 0) return `còn ${days}d ${hours}h`
return <span className="text-amber-600">còn {hours}h</span>
}
export function ContractsListPage() {
const navigate = useNavigate()
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [phase, setPhase] = useState<string>('')
const list = useQuery({
queryKey: ['contracts', { page, search, phase }],
queryFn: async () => {
const res = await api.get<Paged<ContractListItem>>('/contracts', {
params: { page, pageSize: 20, search: search || undefined, phase: phase || undefined },
})
return res.data
},
})
const columns: Column<ContractListItem>[] = [
{ key: 'maHopDong', header: 'Mã HĐ', width: 'w-48', render: c => <span className="font-mono text-xs">{c.maHopDong ?? '—'}</span> },
{ key: 'tenHopDong', header: 'Tên HĐ', render: c => c.tenHopDong ?? '—' },
{ key: 'type', header: 'Loại', width: 'w-32', render: c => ContractTypeLabel[c.type] ?? '—' },
{ key: 'phase', header: 'Phase', width: 'w-36', render: c => <PhaseBadge phase={c.phase} /> },
{ key: 'supplierName', header: 'NCC', render: c => c.supplierName },
{ key: 'projectName', header: 'Dự án', render: c => c.projectName },
{ key: 'giaTri', header: 'Giá trị', align: 'right', width: 'w-32', render: c => fmtMoney(c.giaTri) },
{ key: 'slaDeadline', header: 'SLA', width: 'w-32', render: c => fmtSla(c.slaDeadline) },
]
return (
<div className="p-6">
<PageHeader title="Hợp đồng" description="Danh sách toàn bộ HĐ — filter theo phase, NCC, dự án." />
<div className="mb-3 flex gap-2">
<Input
placeholder="Tìm theo mã HĐ, tên, NCC…"
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
className="max-w-sm"
/>
<Select value={phase} onChange={e => { setPhase(e.target.value); setPage(1) }} className="max-w-xs">
<option value="">Tất cả phase</option>
{Object.values(ContractPhase).map(p => (
<option key={p} value={p}>{ContractPhaseLabel[p]}</option>
))}
</Select>
</div>
<DataTable
columns={columns}
rows={list.data?.items ?? []}
getRowKey={c => c.id}
isLoading={list.isLoading}
onRowClick={c => navigate(`/contracts/${c.id}`)}
/>
<Pagination page={page} pageSize={20} total={list.data?.total ?? 0} onChange={setPage} />
</div>
)
}

View File

@ -0,0 +1,122 @@
export const ContractPhase = {
DangChon: 1,
DangSoanThao: 2,
DangGopY: 3,
DangDamPhan: 4,
DangInKy: 5,
DangKiemTraCCM: 6,
DangTrinhKy: 7,
DangDongDau: 8,
DaPhatHanh: 9,
TuChoi: 99,
} as const
export type ContractPhase = typeof ContractPhase[keyof typeof ContractPhase]
export const ContractPhaseLabel: Record<number, string> = {
1: 'Đang chọn NCC',
2: 'Đang soạn thảo',
3: 'Đang góp ý',
4: 'Đang đàm phán',
5: 'Đang in ký',
6: 'CCM kiểm tra',
7: 'Đang trình ký',
8: 'Đang đóng dấu',
9: 'Đã phát hành',
99: 'Từ chối',
}
export const ContractPhaseColor: Record<number, string> = {
1: 'bg-slate-100 text-slate-700',
2: 'bg-blue-100 text-blue-700',
3: 'bg-amber-100 text-amber-700',
4: 'bg-orange-100 text-orange-700',
5: 'bg-purple-100 text-purple-700',
6: 'bg-indigo-100 text-indigo-700',
7: 'bg-fuchsia-100 text-fuchsia-700',
8: 'bg-pink-100 text-pink-700',
9: 'bg-emerald-100 text-emerald-700',
99: 'bg-red-100 text-red-700',
}
export const ApprovalDecision = {
Pending: 0,
Approve: 1,
Reject: 2,
AutoApprove: 3,
} as const
export type ApprovalDecision = typeof ApprovalDecision[keyof typeof ApprovalDecision]
export type ContractListItem = {
id: string
maHopDong: string | null
tenHopDong: string | null
type: number
phase: number
supplierId: string
supplierName: string
projectId: string
projectName: string
giaTri: number
slaDeadline: string | null
createdAt: string
}
export type ContractApproval = {
id: string
fromPhase: number
toPhase: number
approverUserId: string | null
approverName: string | null
decision: number
comment: string | null
approvedAt: string
}
export type ContractComment = {
id: string
userId: string
userName: string
phase: number
content: string
createdAt: string
}
export type ContractAttachment = {
id: string
fileName: string
storagePath: string
fileSize: number
contentType: string
purpose: number
note: string | null
createdAt: string
}
export type ContractDetail = {
id: string
maHopDong: string | null
tenHopDong: string | null
noiDung: string | null
type: number
phase: number
supplierId: string
supplierName: string
projectId: string
projectName: string
departmentId: string | null
departmentName: string | null
drafterUserId: string | null
drafterName: string | null
templateId: string | null
giaTri: number
bypassProcurementAndCCM: boolean
slaDeadline: string | null
draftData: string | null
createdAt: string
updatedAt: string | null
approvals: ContractApproval[]
comments: ContractComment[]
attachments: ContractAttachment[]
}

View File

@ -5,6 +5,9 @@ import { ProtectedRoute } from '@/components/ProtectedRoute'
import { Layout } from '@/components/Layout'
import { LoginPage } from '@/pages/LoginPage'
import { InboxPage } from '@/pages/InboxPage'
import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage'
import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
import { MyContractsPage } from '@/pages/contracts/MyContractsPage'
function App() {
return (
@ -20,12 +23,15 @@ function App() {
}
>
<Route path="/inbox" element={<InboxPage />} />
<Route path="/contracts/new" element={<ContractCreatePage />} />
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/my-contracts" element={<MyContractsPage />} />
<Route path="/" element={<Navigate to="/inbox" replace />} />
<Route
path="*"
element={
<div className="p-8 text-slate-500">
Trang này chưa đưc build sẽ Phase 1 đt 2 / Phase 2 / 3.
Trang này chưa đưc build.
</div>
}
/>

View File

@ -0,0 +1,151 @@
import type { ReactNode } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/cn'
export type Column<T> = {
key: string
header: ReactNode
render: (row: T) => ReactNode
sortable?: boolean
width?: string
align?: 'left' | 'center' | 'right'
}
type Props<T> = {
columns: Column<T>[]
rows: T[]
getRowKey: (row: T) => string
isLoading?: boolean
empty?: ReactNode
sortBy?: string
sortDesc?: boolean
onSortChange?: (sortBy: string, sortDesc: boolean) => void
onRowClick?: (row: T) => void
}
export function DataTable<T>({
columns,
rows,
getRowKey,
isLoading,
empty,
sortBy,
sortDesc,
onSortChange,
onRowClick,
}: Props<T>) {
return (
<div className="overflow-auto rounded-md border border-slate-200 bg-white">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-700">
<tr>
{columns.map(c => (
<th
key={c.key}
className={cn(
'px-3 py-2 font-medium',
c.align === 'right' && 'text-right',
c.align === 'center' && 'text-center',
c.align !== 'right' && c.align !== 'center' && 'text-left',
c.width,
)}
>
{c.sortable && onSortChange ? (
<button
onClick={() => onSortChange(c.key, sortBy === c.key ? !sortDesc : false)}
className="inline-flex items-center gap-1 hover:text-slate-900"
>
{c.header}
{sortBy === c.key && (sortDesc ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronUp className="h-3.5 w-3.5" />)}
</button>
) : (
c.header
)}
</th>
))}
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={columns.length} className="px-3 py-8 text-center text-slate-500">
Đang tải
</td>
</tr>
)}
{!isLoading && rows.length === 0 && (
<tr>
<td colSpan={columns.length} className="px-3 py-8 text-center text-slate-500">
{empty ?? 'Không có dữ liệu'}
</td>
</tr>
)}
{!isLoading &&
rows.map(row => (
<tr
key={getRowKey(row)}
className={cn(
'border-t border-slate-100 transition',
onRowClick && 'cursor-pointer hover:bg-slate-50',
)}
onClick={() => onRowClick?.(row)}
>
{columns.map(c => (
<td
key={c.key}
className={cn(
'px-3 py-2',
c.align === 'right' && 'text-right',
c.align === 'center' && 'text-center',
)}
>
{c.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
type PaginationProps = {
page: number
pageSize: number
total: number
onChange: (page: number) => void
}
export function Pagination({ page, pageSize, total, onChange }: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const from = total === 0 ? 0 : (page - 1) * pageSize + 1
const to = Math.min(page * pageSize, total)
return (
<div className="flex items-center justify-between py-3 text-sm text-slate-600">
<span>
{from}{to} / {total}
</span>
<div className="flex gap-1">
<button
disabled={page <= 1}
onClick={() => onChange(page - 1)}
className="rounded-md border border-slate-300 bg-white px-3 py-1 disabled:opacity-50"
>
Trước
</button>
<span className="px-3 py-1">
Trang {page}/{totalPages}
</span>
<button
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
className="rounded-md border border-slate-300 bg-white px-3 py-1 disabled:opacity-50"
>
Sau
</button>
</div>
</div>
)
}

View File

@ -1,12 +1,20 @@
import { Link, NavLink, Outlet } from 'react-router-dom'
import { LogOut, Inbox, FileText, Plus } from 'lucide-react'
import { LogOut, Circle, Inbox, FileText, Plus, type LucideIcon } from 'lucide-react'
import * as Icons from 'lucide-react'
import { useAuth } from '@/contexts/AuthContext'
import { cn } from '@/lib/cn'
const menuItems = [
{ to: '/inbox', label: 'Chờ xử lý', icon: Inbox },
{ to: '/my-contracts', label: 'HĐ của tôi', icon: FileText },
function getIcon(name: string | null): LucideIcon {
if (!name) return Circle
const cand = (Icons as unknown as Record<string, LucideIcon>)[name]
return cand ?? Circle
}
// Menu fixed cho fe-user (không show tree động vì user-flow đơn giản)
const USER_MENU = [
{ to: '/inbox', label: 'HĐ chờ xử lý', icon: Inbox },
{ to: '/contracts/new', label: 'Tạo HĐ mới', icon: Plus },
{ to: '/my-contracts', label: 'HĐ của tôi', icon: FileText },
]
export function Layout() {
@ -21,28 +29,32 @@ export function Layout() {
</Link>
</div>
<nav className="flex-1 space-y-1 p-3">
{menuItems.map(item => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition',
isActive
? 'bg-brand-50 text-brand-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
)
}
>
<item.icon className="h-4 w-4" />
{item.label}
</NavLink>
))}
{USER_MENU.map(item => {
const Icon = item.icon ?? getIcon(null)
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition',
isActive ? 'bg-brand-50 text-brand-700' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
)
}
>
<Icon className="h-4 w-4" />
{item.label}
</NavLink>
)
})}
</nav>
<div className="border-t border-slate-200 p-3">
<div className="mb-2 px-3 text-xs text-slate-500">
<div className="font-medium text-slate-700">{user?.fullName}</div>
<div className="truncate font-medium text-slate-700">{user?.fullName}</div>
<div className="truncate">{user?.email}</div>
{user && user.roles.length > 0 && (
<div className="mt-1 font-mono text-[10px]">{user.roles.join(', ')}</div>
)}
</div>
<button
onClick={logout}

View File

@ -0,0 +1,13 @@
import type { ReactNode } from 'react'
export function PageHeader({ title, description, actions }: { title: ReactNode; description?: ReactNode; actions?: ReactNode }) {
return (
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<h1 className="text-xl font-bold text-slate-900">{title}</h1>
{description && <p className="mt-1 text-sm text-slate-600">{description}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
)
}

View File

@ -0,0 +1,16 @@
import type { ReactNode } from 'react'
import { usePermission } from '@/hooks/usePermission'
import type { CrudAction } from '@/lib/menuKeys'
type Props = {
menuKey: string
action?: CrudAction
fallback?: ReactNode
children: ReactNode
}
export function PermissionGuard({ menuKey, action = 'Read', fallback = null, children }: Props) {
const { can } = usePermission()
if (!can(menuKey, action)) return <>{fallback}</>
return <>{children}</>
}

View File

@ -0,0 +1,10 @@
import { cn } from '@/lib/cn'
import { ContractPhaseColor, ContractPhaseLabel } from '@/types/contracts'
export function PhaseBadge({ phase, className }: { phase: number; className?: string }) {
return (
<span className={cn('inline-flex rounded-full px-2 py-0.5 text-xs font-medium', ContractPhaseColor[phase], className)}>
{ContractPhaseLabel[phase]}
</span>
)
}

View File

@ -0,0 +1,48 @@
import { useEffect, type ReactNode } from 'react'
import { X } from 'lucide-react'
import { cn } from '@/lib/cn'
type Props = {
open: boolean
onClose: () => void
title: ReactNode
children: ReactNode
footer?: ReactNode
size?: 'sm' | 'md' | 'lg'
}
export function Dialog({ open, onClose, title, children, footer, size = 'md' }: Props) {
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<div
className={cn(
'w-full rounded-lg bg-white shadow-xl',
size === 'sm' && 'max-w-md',
size === 'md' && 'max-w-xl',
size === 'lg' && 'max-w-3xl',
)}
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-3">
<div className="text-base font-semibold text-slate-900">{title}</div>
<button onClick={onClose} className="rounded p-1 text-slate-500 hover:bg-slate-100">
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[70vh] overflow-auto p-5">{children}</div>
{footer && <div className="flex items-center justify-end gap-2 border-t border-slate-200 px-5 py-3">{footer}</div>}
</div>
</div>
)
}

View File

@ -0,0 +1,18 @@
import { forwardRef, type SelectHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
type Props = SelectHTMLAttributes<HTMLSelectElement>
export const Select = forwardRef<HTMLSelectElement, Props>(({ className, children, ...props }, ref) => (
<select
ref={ref}
className={cn(
'h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50',
className,
)}
{...props}
>
{children}
</select>
))
Select.displayName = 'Select'

View File

@ -0,0 +1,16 @@
import { forwardRef, type TextareaHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'
type Props = TextareaHTMLAttributes<HTMLTextAreaElement>
export const Textarea = forwardRef<HTMLTextAreaElement, Props>(({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
'w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50',
className,
)}
{...props}
/>
))
Textarea.displayName = 'Textarea'

View File

@ -1,29 +1,48 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { api, TOKEN_KEY, USER_KEY } from '@/lib/api'
import type { AuthResponse, LoginPayload, UserInfo } from '@/types/auth'
import type { MenuNode } from '@/types/menu'
type AuthContextValue = {
user: UserInfo | null
menu: MenuNode[]
isAuthenticated: boolean
isBootstrapping: boolean
login: (payload: LoginPayload) => Promise<void>
logout: () => void
refreshMenu: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
const MENU_KEY = 'solution-erp-user-menu'
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserInfo | null>(null)
const [menu, setMenu] = useState<MenuNode[]>([])
const [isBootstrapping, setIsBootstrapping] = useState(true)
async function loadMenu() {
try {
const res = await api.get<MenuNode[]>('/menus/me')
setMenu(res.data)
localStorage.setItem(MENU_KEY, JSON.stringify(res.data))
} catch {
// keep cached
}
}
useEffect(() => {
const token = localStorage.getItem(TOKEN_KEY)
const raw = localStorage.getItem(USER_KEY)
if (token && raw) {
const userRaw = localStorage.getItem(USER_KEY)
const menuRaw = localStorage.getItem(MENU_KEY)
if (token && userRaw) {
try {
setUser(JSON.parse(raw))
setUser(JSON.parse(userRaw))
if (menuRaw) setMenu(JSON.parse(menuRaw))
loadMenu()
} catch {
localStorage.removeItem(USER_KEY)
localStorage.removeItem(MENU_KEY)
}
}
setIsBootstrapping(false)
@ -34,16 +53,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.setItem(TOKEN_KEY, res.data.accessToken)
localStorage.setItem(USER_KEY, JSON.stringify(res.data.user))
setUser(res.data.user)
await loadMenu()
}
function logout() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
localStorage.removeItem(MENU_KEY)
setUser(null)
setMenu([])
}
return (
<AuthContext.Provider value={{ user, isAuthenticated: !!user, isBootstrapping, login, logout }}>
<AuthContext.Provider value={{ user, menu, isAuthenticated: !!user, isBootstrapping, login, logout, refreshMenu: loadMenu }}>
{children}
</AuthContext.Provider>
)

View File

@ -0,0 +1,26 @@
import { useAuth } from '@/contexts/AuthContext'
import type { CrudAction } from '@/lib/menuKeys'
import type { MenuNode } from '@/types/menu'
function findNode(tree: MenuNode[], key: string): MenuNode | undefined {
for (const n of tree) {
if (n.key === key) return n
const found = findNode(n.children, key)
if (found) return found
}
return undefined
}
export function usePermission() {
const { menu } = useAuth()
return {
can: (menuKey: string, action: CrudAction = 'Read'): boolean => {
const node = findNode(menu, menuKey)
if (!node) return false
return action === 'Read' ? node.canRead
: action === 'Create' ? node.canCreate
: action === 'Update' ? node.canUpdate
: node.canDelete
},
}
}

View File

@ -0,0 +1,13 @@
import axios from 'axios'
export function getErrorMessage(err: unknown, fallback = 'Lỗi hệ thống'): string {
if (axios.isAxiosError(err)) {
const data = err.response?.data as { detail?: string; title?: string; errors?: Record<string, string[]> } | undefined
if (data?.errors) {
const first = Object.values(data.errors).flat()[0]
if (first) return first
}
return data?.detail ?? data?.title ?? err.message
}
return fallback
}

View File

@ -0,0 +1,18 @@
// Đồng bộ tay với BE SolutionErp.Domain.Identity.MenuKeys
export const MenuKeys = {
Dashboard: 'Dashboard',
Master: 'Master',
Suppliers: 'Suppliers',
Projects: 'Projects',
Departments: 'Departments',
Contracts: 'Contracts',
Forms: 'Forms',
Reports: 'Reports',
System: 'System',
Users: 'Users',
Roles: 'Roles',
Permissions: 'Permissions',
} as const
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
export type CrudAction = 'Read' | 'Create' | 'Update' | 'Delete'

View File

@ -1,18 +1,64 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { Inbox } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, type Column } from '@/components/DataTable'
import { PhaseBadge } from '@/components/PhaseBadge'
import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import type { ContractListItem } from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
const fmtSla = (s: string | null) => {
if (!s) return '—'
const ms = new Date(s).getTime() - Date.now()
const days = Math.floor(ms / (1000 * 60 * 60 * 24))
const hours = Math.floor((ms % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
if (ms < 0) return <span className="text-red-600">Quá hạn</span>
if (days > 0) return `còn ${days}d ${hours}h`
return <span className="text-amber-600">còn {hours}h</span>
}
export function InboxPage() {
const navigate = useNavigate()
const { user } = useAuth()
const list = useQuery({
queryKey: ['inbox'],
queryFn: async () => (await api.get<ContractListItem[]>('/contracts/inbox')).data,
})
const columns: Column<ContractListItem>[] = [
{ key: 'maHopDong', header: 'Mã HĐ', width: 'w-48', render: c => <span className="font-mono text-xs">{c.maHopDong ?? '—'}</span> },
{ key: 'tenHopDong', header: 'Tên HĐ', render: c => c.tenHopDong ?? '—' },
{ key: 'type', header: 'Loại', width: 'w-32', render: c => ContractTypeLabel[c.type] ?? '—' },
{ key: 'phase', header: 'Phase hiện tại', width: 'w-36', render: c => <PhaseBadge phase={c.phase} /> },
{ key: 'supplierName', header: 'NCC', render: c => c.supplierName },
{ key: 'giaTri', header: 'Giá trị', align: 'right', width: 'w-32', render: c => fmtMoney(c.giaTri) },
{ key: 'slaDeadline', header: 'SLA', width: 'w-32', render: c => fmtSla(c.slaDeadline) },
]
return (
<div className="p-8">
<h1 className="text-2xl font-bold text-slate-900">Hộp thư chờ xử </h1>
<p className="mt-2 text-slate-600">
Xin chào <span className="font-medium">{user?.fullName}</span>. Vai trò:{' '}
<span className="font-mono text-sm">{user?.roles.join(', ')}</span>
</p>
<div className="mt-6 rounded-lg border border-slate-200 bg-white p-6 text-sm text-slate-500">
Danh sách chờ role của bạn xử sẽ hiển thị đây (Phase 3).
</div>
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-2">
<Inbox className="h-5 w-5" />
Hộp thư
</span>
}
description={`HĐ chờ vai trò ${user?.roles.join(', ') ?? ''} xử lý. Click row để xem chi tiết.`}
/>
<DataTable
columns={columns}
rows={list.data ?? []}
getRowKey={c => c.id}
isLoading={list.isLoading}
empty="Không có HĐ nào chờ bạn xử lý."
onRowClick={c => navigate(`/contracts/${c.id}`)}
/>
</div>
)
}

View File

@ -0,0 +1,149 @@
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
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 type { Paged, Project, Supplier } from '@/types/master'
import type { ContractTemplate } from '@/types/forms'
import { ContractTypeLabel } from '@/types/forms'
export function ContractCreatePage() {
const navigate = useNavigate()
const [type, setType] = useState(2)
const [supplierId, setSupplierId] = useState('')
const [projectId, setProjectId] = useState('')
const [templateId, setTemplateId] = useState('')
const [giaTri, setGiaTri] = useState('')
const [tenHopDong, setTenHopDong] = useState('')
const [noiDung, setNoiDung] = useState('')
const [bypass, setBypass] = useState(false)
const suppliers = useQuery({
queryKey: ['suppliers-all'],
queryFn: async () => (await api.get<Paged<Supplier>>('/suppliers', { params: { page: 1, pageSize: 200 } })).data.items,
})
const projects = useQuery({
queryKey: ['projects-all'],
queryFn: async () => (await api.get<Paged<Project>>('/projects', { params: { page: 1, pageSize: 200 } })).data.items,
})
const templates = useQuery({
queryKey: ['templates-by-type', type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
})
const create = useMutation({
mutationFn: async () => {
const res = await api.post<{ id: string }>('/contracts', {
type: Number(type),
supplierId,
projectId,
departmentId: null,
templateId: templateId || null,
giaTri: giaTri ? Number(giaTri) : 0,
tenHopDong: tenHopDong || null,
noiDung: noiDung || null,
bypassProcurementAndCCM: bypass,
draftData: null,
})
return res.data.id
},
onSuccess: id => {
toast.success('Đã tạo HĐ draft')
navigate(`/contracts/${id}`)
},
onError: err => toast.error(getErrorMessage(err)),
})
function submit(e: FormEvent) {
e.preventDefault()
if (!supplierId || !projectId) {
toast.error('Chọn NCC và dự án')
return
}
create.mutate()
}
return (
<div className="p-6">
<PageHeader title="Tạo hợp đồng mới" description="Điền thông tin cơ bản. Sau đó có thể bổ sung + submit lên phase góp ý." />
<form onSubmit={submit} className="max-w-3xl space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Loại *</Label>
<Select value={type} onChange={e => setType(Number(e.target.value))}>
{Object.entries(ContractTypeLabel).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>Template (optional)</Label>
<Select value={templateId} onChange={e => setTemplateId(e.target.value)}>
<option value=""> Chưa chọn </option>
{templates.data?.filter(t => t.isActive).map(t => (
<option key={t.id} value={t.id}>{t.formCode} {t.name}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>NCC *</Label>
<Select value={supplierId} onChange={e => setSupplierId(e.target.value)} required>
<option value=""> Chọn </option>
{suppliers.data?.map(s => (
<option key={s.id} value={s.id}>{s.code} {s.name}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>Dự án *</Label>
<Select value={projectId} onChange={e => setProjectId(e.target.value)} required>
<option value=""> Chọn </option>
{projects.data?.map(p => (
<option key={p.id} value={p.id}>{p.code} {p.name}</option>
))}
</Select>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Tên </Label>
<Input value={tenHopDong} onChange={e => setTenHopDong(e.target.value)} placeholder="vd: HĐ giao khoán nhân công dự án FLOCK 01" />
</div>
<div className="space-y-1.5">
<Label>Giá trị (VND)</Label>
<Input type="number" value={giaTri} onChange={e => setGiaTri(e.target.value)} />
</div>
<div className="flex items-end gap-2 pb-2">
<input
type="checkbox"
id="bypass"
checked={bypass}
onChange={e => setBypass(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
<Label htmlFor="bypass" className="cursor-pointer"> với Chủ đu (bypass CCM)</Label>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Nội dung / ghi chú</Label>
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} />
</div>
</div>
<div className="flex gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)}>Hủy</Button>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? 'Đang tạo…' : 'Tạo HĐ draft'}
</Button>
</div>
</form>
</div>
)
}

View File

@ -0,0 +1,241 @@
// Fe-user version identical with fe-admin ContractDetailPage.
// Duplicate có chủ đích (theo convention dự án).
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate, useParams } from 'react-router-dom'
import { ArrowRight, CheckCircle2, MessageSquare, Clock, ArrowLeft, XCircle } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { PhaseBadge } from '@/components/PhaseBadge'
import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea'
import { Dialog } from '@/components/ui/Dialog'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import {
ApprovalDecision,
ContractPhase,
ContractPhaseLabel,
type ContractDetail,
} from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') + ' VND'
const NEXT_PHASES: Record<number, number[]> = {
[ContractPhase.DangSoanThao]: [ContractPhase.DangGopY, ContractPhase.TuChoi],
[ContractPhase.DangGopY]: [ContractPhase.DangDamPhan, ContractPhase.DangSoanThao],
[ContractPhase.DangDamPhan]: [ContractPhase.DangInKy],
[ContractPhase.DangInKy]: [ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy],
[ContractPhase.DangKiemTraCCM]: [ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao],
[ContractPhase.DangTrinhKy]: [ContractPhase.DangDongDau, ContractPhase.DangSoanThao],
[ContractPhase.DangDongDau]: [ContractPhase.DaPhatHanh],
}
export function ContractDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const qc = useQueryClient()
const [actionOpen, setActionOpen] = useState(false)
const [targetPhase, setTargetPhase] = useState<number>(0)
const [decision, setDecision] = useState<number>(ApprovalDecision.Approve)
const [comment, setComment] = useState('')
const [commentInput, setCommentInput] = useState('')
const detail = useQuery({
queryKey: ['contract', id],
queryFn: async () => (await api.get<ContractDetail>(`/contracts/${id}`)).data,
enabled: !!id,
})
const transition = useMutation({
mutationFn: async () => {
await api.post(`/contracts/${id}/transitions`, { targetPhase, decision, comment: comment || null })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract', id] })
qc.invalidateQueries({ queryKey: ['inbox'] })
toast.success('Đã chuyển phase')
setActionOpen(false)
setComment('')
},
onError: err => toast.error(getErrorMessage(err)),
})
const addComment = useMutation({
mutationFn: async (content: string) => {
await api.post(`/contracts/${id}/comments`, { content })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract', id] })
setCommentInput('')
toast.success('Đã gửi')
},
onError: err => toast.error(getErrorMessage(err)),
})
if (detail.isLoading) return <div className="p-8 text-slate-500">Đang tải</div>
if (!detail.data) return <div className="p-8 text-slate-500">Không tìm thấy .</div>
const c = detail.data
const availableTargets = NEXT_PHASES[c.phase] ?? []
function openAction(decisionType: number) {
const targets = NEXT_PHASES[c.phase] ?? []
const defaultTarget = decisionType === ApprovalDecision.Reject
? targets.find(t => t === ContractPhase.DangSoanThao) ?? targets[0]
: targets[0]
setTargetPhase(defaultTarget)
setDecision(decisionType)
setActionOpen(true)
}
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-3">
<button onClick={() => navigate(-1)} className="text-slate-400 hover:text-slate-700">
<ArrowLeft className="h-5 w-5" />
</button>
{c.tenHopDong ?? 'HĐ chưa đặt tên'}
</span>
}
description={
<span className="flex items-center gap-3">
<span className="font-mono text-xs">{c.maHopDong ?? '(chưa có mã)'}</span>
<PhaseBadge phase={c.phase} />
</span>
}
actions={
<div className="flex gap-2">
{availableTargets.length > 0 && (
<>
<Button variant="outline" onClick={() => openAction(ApprovalDecision.Reject)}>
<XCircle className="h-4 w-4" />
Yêu cầu sửa
</Button>
<Button onClick={() => openAction(ApprovalDecision.Approve)}>
<CheckCircle2 className="h-4 w-4" />
Duyệt tiếp
</Button>
</>
)}
</div>
}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-2">
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-3 text-sm font-semibold text-slate-700">Thông tin </h2>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div><dt className="text-slate-500">Loại</dt><dd>{ContractTypeLabel[c.type] ?? '—'}</dd></div>
<div><dt className="text-slate-500">Giá trị</dt><dd>{fmtMoney(c.giaTri)}</dd></div>
<div><dt className="text-slate-500">NCC</dt><dd>{c.supplierName}</dd></div>
<div><dt className="text-slate-500">Dự án</dt><dd>{c.projectName}</dd></div>
<div><dt className="text-slate-500">Người soạn</dt><dd>{c.drafterName ?? '—'}</dd></div>
<div><dt className="text-slate-500">SLA</dt><dd>{c.slaDeadline ? fmt(c.slaDeadline) : '—'}</dd></div>
</dl>
{c.noiDung && (
<div className="mt-3">
<dt className="text-sm text-slate-500">Nội dung</dt>
<dd className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{c.noiDung}</dd>
</div>
)}
</section>
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<MessageSquare className="h-4 w-4" />
Góp ý ({c.comments.length})
</h2>
<div className="space-y-3">
{c.comments.length === 0 && <div className="text-sm text-slate-400">Chưa góp ý.</div>}
{c.comments.map(cm => (
<div key={cm.id} className="rounded-md border border-slate-100 p-3">
<div className="flex items-center justify-between text-xs text-slate-500">
<span className="font-medium text-slate-700">{cm.userName}</span>
<span>{fmt(cm.createdAt)} · {ContractPhaseLabel[cm.phase]}</span>
</div>
<div className="mt-1 whitespace-pre-wrap text-sm text-slate-700">{cm.content}</div>
</div>
))}
</div>
<form
className="mt-4 flex gap-2"
onSubmit={(e: FormEvent) => {
e.preventDefault()
if (!commentInput.trim()) return
addComment.mutate(commentInput.trim())
}}
>
<Textarea rows={2} placeholder="Thêm góp ý…" value={commentInput} onChange={e => setCommentInput(e.target.value)} />
<Button type="submit" disabled={addComment.isPending || !commentInput.trim()}>
Gửi
</Button>
</form>
</section>
</div>
<aside>
<section className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
<Clock className="h-4 w-4" />
Lịch sử ({c.approvals.length})
</h2>
<ol className="space-y-3">
{c.approvals.length === 0 && <li className="text-sm text-slate-400">Chưa .</li>}
{c.approvals.map(a => (
<li key={a.id} className="flex gap-3">
<div className="mt-1 h-2 w-2 rounded-full bg-brand-500" />
<div className="flex-1 space-y-0.5 text-sm">
<div className="flex items-center gap-1">
<PhaseBadge phase={a.fromPhase} className="text-[10px]" />
<ArrowRight className="h-3 w-3 text-slate-400" />
<PhaseBadge phase={a.toPhase} className="text-[10px]" />
</div>
<div className="text-slate-700">{a.approverName ?? 'Hệ thống'}</div>
<div className="text-xs text-slate-500">{fmt(a.approvedAt)}</div>
{a.comment && <div className="mt-1 rounded bg-slate-50 px-2 py-1 text-xs text-slate-600">{a.comment}</div>}
</div>
</li>
))}
</ol>
</section>
</aside>
</div>
<Dialog
open={actionOpen}
onClose={() => setActionOpen(false)}
title={decision === ApprovalDecision.Reject ? 'Yêu cầu sửa' : 'Chuyển phase tiếp'}
footer={
<>
<Button variant="outline" onClick={() => setActionOpen(false)}>Hủy</Button>
<Button onClick={() => transition.mutate()} disabled={transition.isPending || !targetPhase}>
{transition.isPending ? 'Đang xử lý…' : 'Xác nhận'}
</Button>
</>
}
>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700">Chuyển đến phase</label>
<Select value={targetPhase} onChange={e => setTargetPhase(Number(e.target.value))}>
{availableTargets.map(p => (
<option key={p} value={p}>{ContractPhaseLabel[p]}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700">Ghi chú (optional)</label>
<Textarea rows={3} value={comment} onChange={e => setComment(e.target.value)} />
</div>
</div>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,53 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { FileText } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { DataTable, type Column } from '@/components/DataTable'
import { PhaseBadge } from '@/components/PhaseBadge'
import { api } from '@/lib/api'
import type { Paged } from '@/types/master'
import type { ContractListItem } from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function MyContractsPage() {
const navigate = useNavigate()
const list = useQuery({
queryKey: ['my-contracts'],
queryFn: async () => (await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data,
})
const columns: Column<ContractListItem>[] = [
{ key: 'maHopDong', header: 'Mã HĐ', width: 'w-48', render: c => <span className="font-mono text-xs">{c.maHopDong ?? '—'}</span> },
{ key: 'tenHopDong', header: 'Tên', render: c => c.tenHopDong ?? '—' },
{ key: 'type', header: 'Loại', width: 'w-32', render: c => ContractTypeLabel[c.type] ?? '—' },
{ key: 'phase', header: 'Phase', width: 'w-36', render: c => <PhaseBadge phase={c.phase} /> },
{ key: 'supplierName', header: 'NCC', render: c => c.supplierName },
{ key: 'giaTri', header: 'Giá trị', align: 'right', width: 'w-32', render: c => fmtMoney(c.giaTri) },
]
return (
<div className="p-6">
<PageHeader
title={
<span className="flex items-center gap-2">
<FileText className="h-5 w-5" />
của tôi
</span>
}
description="Danh sách HĐ bạn đã tạo hoặc tham gia."
/>
<DataTable
columns={columns}
rows={list.data?.items ?? []}
getRowKey={c => c.id}
isLoading={list.isLoading}
empty="Bạn chưa tạo HĐ nào."
onRowClick={c => navigate(`/contracts/${c.id}`)}
/>
</div>
)
}

View File

@ -0,0 +1,122 @@
export const ContractPhase = {
DangChon: 1,
DangSoanThao: 2,
DangGopY: 3,
DangDamPhan: 4,
DangInKy: 5,
DangKiemTraCCM: 6,
DangTrinhKy: 7,
DangDongDau: 8,
DaPhatHanh: 9,
TuChoi: 99,
} as const
export type ContractPhase = typeof ContractPhase[keyof typeof ContractPhase]
export const ContractPhaseLabel: Record<number, string> = {
1: 'Đang chọn NCC',
2: 'Đang soạn thảo',
3: 'Đang góp ý',
4: 'Đang đàm phán',
5: 'Đang in ký',
6: 'CCM kiểm tra',
7: 'Đang trình ký',
8: 'Đang đóng dấu',
9: 'Đã phát hành',
99: 'Từ chối',
}
export const ContractPhaseColor: Record<number, string> = {
1: 'bg-slate-100 text-slate-700',
2: 'bg-blue-100 text-blue-700',
3: 'bg-amber-100 text-amber-700',
4: 'bg-orange-100 text-orange-700',
5: 'bg-purple-100 text-purple-700',
6: 'bg-indigo-100 text-indigo-700',
7: 'bg-fuchsia-100 text-fuchsia-700',
8: 'bg-pink-100 text-pink-700',
9: 'bg-emerald-100 text-emerald-700',
99: 'bg-red-100 text-red-700',
}
export const ApprovalDecision = {
Pending: 0,
Approve: 1,
Reject: 2,
AutoApprove: 3,
} as const
export type ApprovalDecision = typeof ApprovalDecision[keyof typeof ApprovalDecision]
export type ContractListItem = {
id: string
maHopDong: string | null
tenHopDong: string | null
type: number
phase: number
supplierId: string
supplierName: string
projectId: string
projectName: string
giaTri: number
slaDeadline: string | null
createdAt: string
}
export type ContractApproval = {
id: string
fromPhase: number
toPhase: number
approverUserId: string | null
approverName: string | null
decision: number
comment: string | null
approvedAt: string
}
export type ContractComment = {
id: string
userId: string
userName: string
phase: number
content: string
createdAt: string
}
export type ContractAttachment = {
id: string
fileName: string
storagePath: string
fileSize: number
contentType: string
purpose: number
note: string | null
createdAt: string
}
export type ContractDetail = {
id: string
maHopDong: string | null
tenHopDong: string | null
noiDung: string | null
type: number
phase: number
supplierId: string
supplierName: string
projectId: string
projectName: string
departmentId: string | null
departmentName: string | null
drafterUserId: string | null
drafterName: string | null
templateId: string | null
giaTri: number
bypassProcurementAndCCM: boolean
slaDeadline: string | null
draftData: string | null
createdAt: string
updatedAt: string | null
approvals: ContractApproval[]
comments: ContractComment[]
attachments: ContractAttachment[]
}

View File

@ -0,0 +1,21 @@
export type ContractTemplate = {
id: string
formCode: string
name: string
contractType: number | null
fileName: string
format: 'docx' | 'xlsx'
fieldSpec: string | null
description: string | null
isActive: boolean
}
export const ContractTypeLabel: Record<number, string> = {
1: 'HĐ Thầu phụ',
2: 'HĐ Giao khoán',
3: 'HĐ Nhà cung cấp',
4: 'HĐ Dịch vụ',
5: 'HĐ Mua bán',
6: 'HĐ Nguyên tắc NCC',
7: 'HĐ Nguyên tắc Dịch vụ',
}

View File

@ -0,0 +1,71 @@
export type Paged<T> = {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
hasNext: boolean
hasPrev: boolean
}
export const SupplierType = {
NhaCungCap: 1,
NhaThauPhu: 2,
ToDoi: 3,
DonViDichVu: 4,
ChuDauTu: 5,
} as const
export type SupplierType = typeof SupplierType[keyof typeof SupplierType]
export const SupplierTypeLabel: Record<SupplierType, string> = {
1: 'Nhà cung cấp',
2: 'Nhà thầu phụ',
3: 'Tổ đội',
4: 'Đơn vị dịch vụ',
5: 'Chủ đầu tư',
}
export type Supplier = {
id: string
code: string
name: string
type: SupplierType
taxCode: string | null
phone: string | null
email: string | null
address: string | null
contactPerson: string | null
note: string | null
createdAt: string
updatedAt: string | null
}
export type SupplierInput = Omit<Supplier, 'id' | 'createdAt' | 'updatedAt'>
export type Project = {
id: string
code: string
name: string
startDate: string | null
endDate: string | null
managerUserId: string | null
budgetTotal: number | null
note: string | null
createdAt: string
updatedAt: string | null
}
export type ProjectInput = Omit<Project, 'id' | 'createdAt' | 'updatedAt'>
export type Department = {
id: string
code: string
name: string
managerUserId: string | null
note: string | null
createdAt: string
updatedAt: string | null
}
export type DepartmentInput = Omit<Department, 'id' | 'createdAt' | 'updatedAt'>

37
fe-user/src/types/menu.ts Normal file
View File

@ -0,0 +1,37 @@
export type MenuNode = {
key: string
label: string
parentKey: string | null
order: number
icon: string | null
canRead: boolean
canCreate: boolean
canUpdate: boolean
canDelete: boolean
children: MenuNode[]
}
export type MenuItem = {
key: string
label: string
parentKey: string | null
order: number
icon: string | null
}
export type Role = {
id: string
name: string
description: string | null
createdAt: string
}
export type Permission = {
id: string
roleId: string
menuKey: string
canRead: boolean
canCreate: boolean
canUpdate: boolean
canDelete: boolean
}

View File

@ -0,0 +1,72 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Contracts;
using SolutionErp.Application.Contracts.Dtos;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/contracts")]
[Authorize]
public class ContractsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PagedResult<ContractListItemDto>>> List(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] bool sortDesc = true,
[FromQuery] ContractPhase? phase = null,
[FromQuery] Guid? supplierId = null,
[FromQuery] Guid? projectId = null,
CancellationToken ct = default)
=> Ok(await mediator.Send(new ListContractsQuery(phase, supplierId, projectId) { Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
[HttpGet("inbox")]
public async Task<ActionResult<List<ContractListItemDto>>> Inbox(CancellationToken ct)
=> Ok(await mediator.Send(new GetMyInboxQuery(), ct));
[HttpGet("{id:guid}")]
public async Task<ActionResult<ContractDetailDto>> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetContractQuery(id), ct));
[HttpPost]
public async Task<ActionResult<object>> Create([FromBody] CreateContractCommand cmd, CancellationToken ct)
{
var id = await mediator.Send(cmd, ct);
return CreatedAtAction(nameof(Get), new { id }, new { id });
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateContractDraftCommand cmd, CancellationToken ct)
{
if (id != cmd.Id) return BadRequest(new { detail = "ID không khớp" });
await mediator.Send(cmd, ct);
return NoContent();
}
[HttpPost("{id:guid}/transitions")]
public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionContractBody body, CancellationToken ct)
{
await mediator.Send(new TransitionContractCommand(id, body.TargetPhase, body.Decision, body.Comment), ct);
return NoContent();
}
[HttpPost("{id:guid}/comments")]
public async Task<ActionResult<object>> AddComment(Guid id, [FromBody] AddCommentBody body, CancellationToken ct)
{
var commentId = await mediator.Send(new AddCommentCommand(id, body.Content), ct);
return Ok(new { id = commentId });
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await mediator.Send(new DeleteContractCommand(id), ct);
return NoContent();
}
}
public record TransitionContractBody(ContractPhase TargetPhase, ApprovalDecision Decision, string? Comment);
public record AddCommentBody(string Content);

View File

@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Forms;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
@ -14,6 +15,11 @@ public interface IApplicationDbContext
DbSet<Permission> Permissions { get; }
DbSet<ContractTemplate> ContractTemplates { get; }
DbSet<ContractClause> ContractClauses { get; }
DbSet<Contract> Contracts { get; }
DbSet<ContractApproval> ContractApprovals { get; }
DbSet<ContractComment> ContractComments { get; }
DbSet<ContractAttachment> ContractAttachments { get; }
DbSet<ContractCodeSequence> ContractCodeSequences { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,357 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Contracts.Dtos;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Application.Contracts;
// ========== CREATE draft ==========
public record CreateContractCommand(
ContractType Type,
Guid SupplierId,
Guid ProjectId,
Guid? DepartmentId,
Guid? TemplateId,
decimal GiaTri,
string? TenHopDong,
string? NoiDung,
bool BypassProcurementAndCCM,
string? DraftData) : IRequest<Guid>;
public class CreateContractCommandValidator : AbstractValidator<CreateContractCommand>
{
public CreateContractCommandValidator()
{
RuleFor(x => x.Type).IsInEnum();
RuleFor(x => x.SupplierId).NotEmpty();
RuleFor(x => x.ProjectId).NotEmpty();
RuleFor(x => x.GiaTri).GreaterThanOrEqualTo(0);
RuleFor(x => x.TenHopDong).MaximumLength(500);
RuleFor(x => x.NoiDung).MaximumLength(2000);
}
}
public class CreateContractCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
IContractWorkflowService workflow) : IRequestHandler<CreateContractCommand, Guid>
{
public async Task<Guid> Handle(CreateContractCommand request, CancellationToken ct)
{
if (!await db.Suppliers.AnyAsync(s => s.Id == request.SupplierId, ct))
throw new NotFoundException("Supplier", request.SupplierId);
if (!await db.Projects.AnyAsync(p => p.Id == request.ProjectId, ct))
throw new NotFoundException("Project", request.ProjectId);
var entity = new Contract
{
Type = request.Type,
Phase = ContractPhase.DangSoanThao,
SupplierId = request.SupplierId,
ProjectId = request.ProjectId,
DepartmentId = request.DepartmentId,
DrafterUserId = currentUser.UserId,
TemplateId = request.TemplateId,
GiaTri = request.GiaTri,
TenHopDong = request.TenHopDong,
NoiDung = request.NoiDung,
BypassProcurementAndCCM = request.BypassProcurementAndCCM,
DraftData = request.DraftData,
SlaDeadline = DateTime.UtcNow.Add(workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
};
db.Contracts.Add(entity);
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
// ========== UPDATE draft ==========
public record UpdateContractDraftCommand(
Guid Id,
decimal GiaTri,
string? TenHopDong,
string? NoiDung,
Guid? TemplateId,
string? DraftData) : IRequest;
public class UpdateContractDraftCommandHandler(IApplicationDbContext db) : IRequestHandler<UpdateContractDraftCommand>
{
public async Task Handle(UpdateContractDraftCommand request, CancellationToken ct)
{
var entity = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.Id, ct)
?? throw new NotFoundException("Contract", request.Id);
if (entity.Phase != ContractPhase.DangSoanThao)
throw new ConflictException("Chỉ được sửa HĐ khi ở phase Đang soạn thảo.");
entity.GiaTri = request.GiaTri;
entity.TenHopDong = request.TenHopDong;
entity.NoiDung = request.NoiDung;
entity.TemplateId = request.TemplateId;
entity.DraftData = request.DraftData;
await db.SaveChangesAsync(ct);
}
}
// ========== TRANSITION phase ==========
public record TransitionContractCommand(
Guid Id,
ContractPhase TargetPhase,
ApprovalDecision Decision,
string? Comment) : IRequest;
public class TransitionContractCommandValidator : AbstractValidator<TransitionContractCommand>
{
public TransitionContractCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.TargetPhase).IsInEnum();
RuleFor(x => x.Decision).IsInEnum();
RuleFor(x => x.Comment).MaximumLength(1000);
}
}
public class TransitionContractCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
IContractWorkflowService workflow) : IRequestHandler<TransitionContractCommand>
{
public async Task Handle(TransitionContractCommand request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var entity = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.Id, ct)
?? throw new NotFoundException("Contract", request.Id);
await workflow.TransitionAsync(
entity,
request.TargetPhase,
currentUser.UserId,
currentUser.Roles,
request.Decision,
request.Comment,
ct);
}
}
// ========== ADD comment ==========
public record AddCommentCommand(Guid ContractId, string Content) : IRequest<Guid>;
public class AddCommentCommandValidator : AbstractValidator<AddCommentCommand>
{
public AddCommentCommandValidator()
{
RuleFor(x => x.ContractId).NotEmpty();
RuleFor(x => x.Content).NotEmpty().MaximumLength(2000);
}
}
public class AddCommentCommandHandler(IApplicationDbContext db, ICurrentUser currentUser) : IRequestHandler<AddCommentCommand, Guid>
{
public async Task<Guid> Handle(AddCommentCommand request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var contract = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.ContractId, ct)
?? throw new NotFoundException("Contract", request.ContractId);
var comment = new ContractComment
{
ContractId = request.ContractId,
UserId = currentUser.UserId.Value,
Phase = contract.Phase,
Content = request.Content,
};
db.ContractComments.Add(comment);
await db.SaveChangesAsync(ct);
return comment.Id;
}
}
// ========== LIST contracts (admin view) ==========
public record ListContractsQuery(
ContractPhase? Phase = null,
Guid? SupplierId = null,
Guid? ProjectId = null) : PagedRequest, IRequest<PagedResult<ContractListItemDto>>;
public class ListContractsQueryHandler(IApplicationDbContext db) : IRequestHandler<ListContractsQuery, PagedResult<ContractListItemDto>>
{
public async Task<PagedResult<ContractListItemDto>> Handle(ListContractsQuery request, CancellationToken ct)
{
var q = from c in db.Contracts.AsNoTracking()
join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.Id
join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id
select new { c, s, p };
if (request.Phase is not null) q = q.Where(x => x.c.Phase == request.Phase);
if (request.SupplierId is not null) q = q.Where(x => x.c.SupplierId == request.SupplierId);
if (request.ProjectId is not null) q = q.Where(x => x.c.ProjectId == request.ProjectId);
if (!string.IsNullOrWhiteSpace(request.Search))
{
var s = request.Search.Trim();
q = q.Where(x =>
(x.c.MaHopDong != null && x.c.MaHopDong.Contains(s)) ||
(x.c.TenHopDong != null && x.c.TenHopDong.Contains(s)) ||
x.s.Name.Contains(s) || x.p.Name.Contains(s));
}
q = request.SortDesc ? q.OrderByDescending(x => x.c.CreatedAt) : q.OrderBy(x => x.c.CreatedAt);
var total = await q.CountAsync(ct);
var items = await q
.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
.Select(x => new ContractListItemDto(
x.c.Id, x.c.MaHopDong, x.c.TenHopDong, x.c.Type, x.c.Phase,
x.c.SupplierId, x.s.Name,
x.c.ProjectId, x.p.Name,
x.c.GiaTri, x.c.SlaDeadline, x.c.CreatedAt))
.ToListAsync(ct);
return new PagedResult<ContractListItemDto>(items, total, request.Page, request.PageSize);
}
}
// ========== INBOX — HĐ chờ role/tôi xử lý ==========
public record GetMyInboxQuery : IRequest<List<ContractListItemDto>>;
public class GetMyInboxQueryHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<GetMyInboxQuery, List<ContractListItemDto>>
{
// Map phase → role nào được xử lý (xem workflow-contract.md)
private static readonly Dictionary<ContractPhase, string[]> PhaseActorRoles = new()
{
[ContractPhase.DangSoanThao] = [AppRoles.Drafter, AppRoles.DeptManager],
[ContractPhase.DangGopY] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment],
[ContractPhase.DangDamPhan] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
[ContractPhase.DangInKy] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
[ContractPhase.DangKiemTraCCM] = [AppRoles.CostControl],
[ContractPhase.DangTrinhKy] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[ContractPhase.DangDongDau] = [AppRoles.HrAdmin],
};
public async Task<List<ContractListItemDto>> Handle(GetMyInboxQuery request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated) throw new UnauthorizedException();
var userRoles = currentUser.Roles;
var isAdmin = userRoles.Contains(AppRoles.Admin);
// Phase phù hợp với role hiện tại (Admin thấy tất cả phase chưa kết thúc)
var eligiblePhases = isAdmin
? PhaseActorRoles.Keys.ToList()
: PhaseActorRoles
.Where(kv => kv.Value.Any(r => userRoles.Contains(r)))
.Select(kv => kv.Key)
.ToList();
if (eligiblePhases.Count == 0) return [];
var q = from c in db.Contracts.AsNoTracking()
join s in db.Suppliers.AsNoTracking() on c.SupplierId equals s.Id
join p in db.Projects.AsNoTracking() on c.ProjectId equals p.Id
where eligiblePhases.Contains(c.Phase)
orderby c.SlaDeadline ?? DateTime.MaxValue
select new ContractListItemDto(
c.Id, c.MaHopDong, c.TenHopDong, c.Type, c.Phase,
c.SupplierId, s.Name, c.ProjectId, p.Name,
c.GiaTri, c.SlaDeadline, c.CreatedAt);
return await q.Take(100).ToListAsync(ct);
}
}
// ========== GET detail ==========
public record GetContractQuery(Guid Id) : IRequest<ContractDetailDto>;
public class GetContractQueryHandler(IApplicationDbContext db, UserManager<User> userManager)
: IRequestHandler<GetContractQuery, ContractDetailDto>
{
public async Task<ContractDetailDto> Handle(GetContractQuery request, CancellationToken ct)
{
var c = await db.Contracts.AsNoTracking()
.Include(x => x.Approvals)
.Include(x => x.Comments)
.Include(x => x.Attachments)
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Contract", request.Id);
var supplier = await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == c.SupplierId, ct);
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == c.ProjectId, ct);
var department = c.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == c.DepartmentId, ct);
// Resolve user names
var userIds = new HashSet<Guid>();
if (c.DrafterUserId is Guid did) userIds.Add(did);
foreach (var a in c.Approvals) if (a.ApproverUserId is Guid aid) userIds.Add(aid);
foreach (var cm in c.Comments) userIds.Add(cm.UserId);
var users = await userManager.Users.AsNoTracking()
.Where(u => userIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
return new ContractDetailDto(
c.Id, c.MaHopDong, c.TenHopDong, c.NoiDung, c.Type, c.Phase,
c.SupplierId, supplier?.Name ?? "",
c.ProjectId, project?.Name ?? "",
c.DepartmentId, department?.Name,
c.DrafterUserId, c.DrafterUserId is Guid d && users.TryGetValue(d, out var dn) ? dn : null,
c.TemplateId, c.GiaTri, c.BypassProcurementAndCCM, c.SlaDeadline, c.DraftData,
c.CreatedAt, c.UpdatedAt,
c.Approvals
.OrderBy(a => a.ApprovedAt)
.Select(a => new ContractApprovalDto(
a.Id, a.FromPhase, a.ToPhase, a.ApproverUserId,
a.ApproverUserId is Guid id && users.TryGetValue(id, out var n) ? n : null,
a.Decision, a.Comment, a.ApprovedAt))
.ToList(),
c.Comments
.OrderBy(cm => cm.CreatedAt)
.Select(cm => new ContractCommentDto(
cm.Id, cm.UserId,
users.TryGetValue(cm.UserId, out var cn) ? cn : "",
cm.Phase, cm.Content, cm.CreatedAt))
.ToList(),
c.Attachments
.OrderBy(att => att.CreatedAt)
.Select(att => new ContractAttachmentDto(
att.Id, att.FileName, att.StoragePath, att.FileSize,
att.ContentType, att.Purpose, att.Note, att.CreatedAt))
.ToList());
}
}
// ========== DELETE (soft) ==========
public record DeleteContractCommand(Guid Id) : IRequest;
public class DeleteContractCommandHandler(IApplicationDbContext db) : IRequestHandler<DeleteContractCommand>
{
public async Task Handle(DeleteContractCommand request, CancellationToken ct)
{
var entity = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.Id, ct)
?? throw new NotFoundException("Contract", request.Id);
if (entity.Phase >= ContractPhase.DangInKy)
throw new ConflictException("Không được xóa HĐ đã qua phase 'Đang in ký'.");
db.Contracts.Remove(entity);
await db.SaveChangesAsync(ct);
}
}

View File

@ -0,0 +1,71 @@
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Contracts.Dtos;
public record ContractListItemDto(
Guid Id,
string? MaHopDong,
string? TenHopDong,
ContractType Type,
ContractPhase Phase,
Guid SupplierId,
string SupplierName,
Guid ProjectId,
string ProjectName,
decimal GiaTri,
DateTime? SlaDeadline,
DateTime CreatedAt);
public record ContractDetailDto(
Guid Id,
string? MaHopDong,
string? TenHopDong,
string? NoiDung,
ContractType Type,
ContractPhase Phase,
Guid SupplierId,
string SupplierName,
Guid ProjectId,
string ProjectName,
Guid? DepartmentId,
string? DepartmentName,
Guid? DrafterUserId,
string? DrafterName,
Guid? TemplateId,
decimal GiaTri,
bool BypassProcurementAndCCM,
DateTime? SlaDeadline,
string? DraftData,
DateTime CreatedAt,
DateTime? UpdatedAt,
List<ContractApprovalDto> Approvals,
List<ContractCommentDto> Comments,
List<ContractAttachmentDto> Attachments);
public record ContractApprovalDto(
Guid Id,
ContractPhase FromPhase,
ContractPhase ToPhase,
Guid? ApproverUserId,
string? ApproverName,
ApprovalDecision Decision,
string? Comment,
DateTime ApprovedAt);
public record ContractCommentDto(
Guid Id,
Guid UserId,
string UserName,
ContractPhase Phase,
string Content,
DateTime CreatedAt);
public record ContractAttachmentDto(
Guid Id,
string FileName,
string StoragePath,
long FileSize,
string ContentType,
AttachmentPurpose Purpose,
string? Note,
DateTime CreatedAt);

View File

@ -0,0 +1,26 @@
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Contracts.Services;
public interface IContractWorkflowService
{
// Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ.
// Tự tạo ContractApproval row + update Phase + SlaDeadline + gen mã HĐ nếu cần.
Task TransitionAsync(
Contract contract,
ContractPhase targetPhase,
Guid? actorUserId,
IReadOnlyList<string> actorRoles,
ApprovalDecision decision,
string? comment,
CancellationToken ct = default);
// SLA còn bao lâu ở phase hiện tại (seconds). Null nếu không có SLA.
TimeSpan? GetPhaseSla(ContractPhase phase);
}
public interface IContractCodeGenerator
{
// Gen mã HĐ theo RG-001 format. Transaction SERIALIZABLE để tránh race.
Task<string> GenerateAsync(Contract contract, string projectCode, string supplierCode, CancellationToken ct = default);
}

View File

@ -0,0 +1,28 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Contracts;
// Aggregate root cho quy trình trình ký HĐ.
// State machine xem docs/workflow-contract.md — 9 phase + 1 TuChoi.
public class Contract : AuditableEntity
{
public string? MaHopDong { get; set; } // Gen khi chuyển phase DangDongDau (RG-001)
public ContractType Type { get; set; }
public ContractPhase Phase { get; set; } = ContractPhase.DangSoanThao;
public Guid SupplierId { get; set; }
public Guid ProjectId { get; set; }
public Guid? DepartmentId { get; set; }
public Guid? DrafterUserId { get; set; } // Người soạn thảo
public Guid? TemplateId { get; set; } // Template dùng để render
public decimal GiaTri { get; set; }
public string? TenHopDong { get; set; }
public string? NoiDung { get; set; }
public bool BypassProcurementAndCCM { get; set; } // HĐ Chủ đầu tư → skip CCM
public DateTime? SlaDeadline { get; set; } // Hết hạn phase hiện tại
public string? DraftData { get; set; } // JSON field values (render template)
public bool SlaWarningSent { get; set; } // Flag để không gửi warning 2 lần
public List<ContractApproval> Approvals { get; set; } = new();
public List<ContractComment> Comments { get; set; } = new();
public List<ContractAttachment> Attachments { get; set; } = new();
}

View File

@ -0,0 +1,18 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Contracts;
// Lịch sử phê duyệt: mỗi lần chuyển phase tạo 1 row.
// ApproverUserId = null nếu là auto-approve do SLA expired.
public class ContractApproval : BaseEntity
{
public Guid ContractId { get; set; }
public ContractPhase FromPhase { get; set; }
public ContractPhase ToPhase { get; set; }
public Guid? ApproverUserId { get; set; }
public ApprovalDecision Decision { get; set; }
public string? Comment { get; set; }
public DateTime ApprovedAt { get; set; }
public Contract? Contract { get; set; }
}

View File

@ -0,0 +1,24 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Contracts;
public enum AttachmentPurpose
{
DraftExport = 1, // File render từ template (.docx/.xlsx) ở phase DangSoanThao
ScannedSigned = 2, // Scan HĐ có chữ ký NCC ở phase DangInKy
SealedCopy = 3, // Scan HĐ đã đóng dấu ở phase DangDongDau
Other = 99,
}
public class ContractAttachment : BaseEntity
{
public Guid ContractId { get; set; }
public string FileName { get; set; } = string.Empty;
public string StoragePath { get; set; } = string.Empty; // relative path under wwwroot/uploads/
public long FileSize { get; set; }
public string ContentType { get; set; } = string.Empty;
public AttachmentPurpose Purpose { get; set; }
public string? Note { get; set; }
public Contract? Contract { get; set; }
}

View File

@ -0,0 +1,11 @@
namespace SolutionErp.Domain.Contracts;
// Sequence generator cho mã HĐ theo RG-001.
// Prefix = phần đầu mã (vd "FLOCK 01/HĐGK/SOL&PVL"). LastSeq tăng dần.
// Update atomic qua transaction SERIALIZABLE.
public class ContractCodeSequence
{
public string Prefix { get; set; } = string.Empty;
public int LastSeq { get; set; }
public DateTime UpdatedAt { get; set; }
}

View File

@ -0,0 +1,14 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Contracts;
// Thread góp ý — dùng chủ yếu ở phase DangGopY nhưng có thể ở bất kỳ phase nào.
public class ContractComment : BaseEntity
{
public Guid ContractId { get; set; }
public Guid UserId { get; set; }
public ContractPhase Phase { get; set; }
public string Content { get; set; } = string.Empty;
public Contract? Contract { get; set; }
}

View File

@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Application.Forms.Services;
using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Forms;
@ -23,6 +24,8 @@ public static class DependencyInjection
services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddSingleton<IFormRenderer, FormRenderer>();
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
services.AddScoped<AuditingInterceptor>();

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Forms;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
@ -19,6 +20,11 @@ public class ApplicationDbContext
public DbSet<Permission> Permissions => Set<Permission>();
public DbSet<ContractTemplate> ContractTemplates => Set<ContractTemplate>();
public DbSet<ContractClause> ContractClauses => Set<ContractClause>();
public DbSet<Contract> Contracts => Set<Contract>();
public DbSet<ContractApproval> ContractApprovals => Set<ContractApproval>();
public DbSet<ContractComment> ContractComments => Set<ContractComment>();
public DbSet<ContractAttachment> ContractAttachments => Set<ContractAttachment>();
public DbSet<ContractCodeSequence> ContractCodeSequences => Set<ContractCodeSequence>();
protected override void OnModelCreating(ModelBuilder builder)
{

View File

@ -0,0 +1,91 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
public class ContractConfiguration : IEntityTypeConfiguration<Contract>
{
public void Configure(EntityTypeBuilder<Contract> b)
{
b.ToTable("Contracts");
b.HasKey(x => x.Id);
b.Property(x => x.MaHopDong).HasMaxLength(100);
b.Property(x => x.Type).HasConversion<int>();
b.Property(x => x.Phase).HasConversion<int>();
b.Property(x => x.GiaTri).HasPrecision(18, 2);
b.Property(x => x.TenHopDong).HasMaxLength(500);
b.Property(x => x.NoiDung).HasMaxLength(2000);
b.Property(x => x.DraftData).HasColumnType("nvarchar(max)");
b.HasIndex(x => x.MaHopDong).IsUnique().HasFilter("[MaHopDong] IS NOT NULL");
b.HasIndex(x => new { x.Phase, x.IsDeleted });
b.HasIndex(x => x.SupplierId);
b.HasIndex(x => x.ProjectId);
b.HasIndex(x => x.SlaDeadline);
b.HasMany(x => x.Approvals).WithOne(a => a.Contract).HasForeignKey(a => a.ContractId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Comments).WithOne(c => c.Contract).HasForeignKey(c => c.ContractId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Attachments).WithOne(a => a.Contract).HasForeignKey(a => a.ContractId).OnDelete(DeleteBehavior.Cascade);
b.HasQueryFilter(x => !x.IsDeleted);
}
}
public class ContractApprovalConfiguration : IEntityTypeConfiguration<ContractApproval>
{
public void Configure(EntityTypeBuilder<ContractApproval> b)
{
b.ToTable("ContractApprovals");
b.HasKey(x => x.Id);
b.Property(x => x.FromPhase).HasConversion<int>();
b.Property(x => x.ToPhase).HasConversion<int>();
b.Property(x => x.Decision).HasConversion<int>();
b.Property(x => x.Comment).HasMaxLength(1000);
b.HasIndex(x => new { x.ContractId, x.ApprovedAt });
}
}
public class ContractCommentConfiguration : IEntityTypeConfiguration<ContractComment>
{
public void Configure(EntityTypeBuilder<ContractComment> b)
{
b.ToTable("ContractComments");
b.HasKey(x => x.Id);
b.Property(x => x.Phase).HasConversion<int>();
b.Property(x => x.Content).HasMaxLength(2000).IsRequired();
b.HasIndex(x => new { x.ContractId, x.CreatedAt });
}
}
public class ContractAttachmentConfiguration : IEntityTypeConfiguration<ContractAttachment>
{
public void Configure(EntityTypeBuilder<ContractAttachment> b)
{
b.ToTable("ContractAttachments");
b.HasKey(x => x.Id);
b.Property(x => x.FileName).HasMaxLength(255).IsRequired();
b.Property(x => x.StoragePath).HasMaxLength(500).IsRequired();
b.Property(x => x.ContentType).HasMaxLength(100).IsRequired();
b.Property(x => x.Purpose).HasConversion<int>();
b.Property(x => x.Note).HasMaxLength(500);
b.HasIndex(x => x.ContractId);
}
}
public class ContractCodeSequenceConfiguration : IEntityTypeConfiguration<ContractCodeSequence>
{
public void Configure(EntityTypeBuilder<ContractCodeSequence> b)
{
b.ToTable("ContractCodeSequences");
b.HasKey(x => x.Prefix);
b.Property(x => x.Prefix).HasMaxLength(200);
}
}

View File

@ -0,0 +1,203 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddContractsWorkflow : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ContractCodeSequences",
columns: table => new
{
Prefix = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
LastSeq = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ContractCodeSequences", x => x.Prefix);
});
migrationBuilder.CreateTable(
name: "Contracts",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MaHopDong = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
Type = table.Column<int>(type: "int", nullable: false),
Phase = table.Column<int>(type: "int", nullable: false),
SupplierId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ProjectId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DrafterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
TemplateId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
GiaTri = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
TenHopDong = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
NoiDung = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
BypassProcurementAndCCM = table.Column<bool>(type: "bit", nullable: false),
SlaDeadline = table.Column<DateTime>(type: "datetime2", nullable: true),
DraftData = table.Column<string>(type: "nvarchar(max)", nullable: true),
SlaWarningSent = table.Column<bool>(type: "bit", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Contracts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ContractApprovals",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
FromPhase = table.Column<int>(type: "int", nullable: false),
ToPhase = table.Column<int>(type: "int", nullable: false),
ApproverUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Decision = table.Column<int>(type: "int", nullable: false),
Comment = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
ApprovedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ContractApprovals", x => x.Id);
table.ForeignKey(
name: "FK_ContractApprovals_Contracts_ContractId",
column: x => x.ContractId,
principalTable: "Contracts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ContractAttachments",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
FileName = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
StoragePath = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
FileSize = table.Column<long>(type: "bigint", nullable: false),
ContentType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Purpose = table.Column<int>(type: "int", nullable: false),
Note = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ContractAttachments", x => x.Id);
table.ForeignKey(
name: "FK_ContractAttachments_Contracts_ContractId",
column: x => x.ContractId,
principalTable: "Contracts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ContractComments",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ContractId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Phase = table.Column<int>(type: "int", nullable: false),
Content = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ContractComments", x => x.Id);
table.ForeignKey(
name: "FK_ContractComments_Contracts_ContractId",
column: x => x.ContractId,
principalTable: "Contracts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ContractApprovals_ContractId_ApprovedAt",
table: "ContractApprovals",
columns: new[] { "ContractId", "ApprovedAt" });
migrationBuilder.CreateIndex(
name: "IX_ContractAttachments_ContractId",
table: "ContractAttachments",
column: "ContractId");
migrationBuilder.CreateIndex(
name: "IX_ContractComments_ContractId_CreatedAt",
table: "ContractComments",
columns: new[] { "ContractId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_Contracts_MaHopDong",
table: "Contracts",
column: "MaHopDong",
unique: true,
filter: "[MaHopDong] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Contracts_Phase_IsDeleted",
table: "Contracts",
columns: new[] { "Phase", "IsDeleted" });
migrationBuilder.CreateIndex(
name: "IX_Contracts_ProjectId",
table: "Contracts",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_Contracts_SlaDeadline",
table: "Contracts",
column: "SlaDeadline");
migrationBuilder.CreateIndex(
name: "IX_Contracts_SupplierId",
table: "Contracts",
column: "SupplierId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ContractApprovals");
migrationBuilder.DropTable(
name: "ContractAttachments");
migrationBuilder.DropTable(
name: "ContractCodeSequences");
migrationBuilder.DropTable(
name: "ContractComments");
migrationBuilder.DropTable(
name: "Contracts");
}
}
}

View File

@ -125,6 +125,255 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<bool>("BypassProcurementAndCCM")
.HasColumnType("bit");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<string>("DraftData")
.HasColumnType("nvarchar(max)");
b.Property<Guid?>("DrafterUserId")
.HasColumnType("uniqueidentifier");
b.Property<decimal>("GiaTri")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("MaHopDong")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("NoiDung")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<int>("Phase")
.HasColumnType("int");
b.Property<Guid>("ProjectId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("SlaDeadline")
.HasColumnType("datetime2");
b.Property<bool>("SlaWarningSent")
.HasColumnType("bit");
b.Property<Guid>("SupplierId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("TemplateId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TenHopDong")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("MaHopDong")
.IsUnique()
.HasFilter("[MaHopDong] IS NOT NULL");
b.HasIndex("ProjectId");
b.HasIndex("SlaDeadline");
b.HasIndex("SupplierId");
b.HasIndex("Phase", "IsDeleted");
b.ToTable("Contracts", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("ApprovedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("ApproverUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<Guid>("ContractId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Decision")
.HasColumnType("int");
b.Property<int>("FromPhase")
.HasColumnType("int");
b.Property<int>("ToPhase")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ContractId", "ApprovedAt");
b.ToTable("ContractApprovals", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractAttachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("ContractId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Purpose")
.HasColumnType("int");
b.Property<string>("StoragePath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ContractId");
b.ToTable("ContractAttachments", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractCodeSequence", b =>
{
b.Property<string>("Prefix")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("LastSeq")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Prefix");
b.ToTable("ContractCodeSequences", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractComment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<Guid>("ContractId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Phase")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ContractId", "CreatedAt");
b.ToTable("ContractComments", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Forms.ContractClause", b =>
{
b.Property<Guid>("Id")
@ -681,6 +930,39 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b =>
{
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
.WithMany("Approvals")
.HasForeignKey("ContractId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Contract");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractAttachment", b =>
{
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
.WithMany("Attachments")
.HasForeignKey("ContractId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Contract");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractComment", b =>
{
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
.WithMany("Comments")
.HasForeignKey("ContractId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Contract");
});
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{
b.HasOne("SolutionErp.Domain.Identity.MenuItem", "Parent")
@ -710,6 +992,15 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Role");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
{
b.Navigation("Approvals");
b.Navigation("Attachments");
b.Navigation("Comments");
});
modelBuilder.Entity("SolutionErp.Domain.Identity.MenuItem", b =>
{
b.Navigation("Children");

View File

@ -0,0 +1,61 @@
using System.Data;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Infrastructure.Services;
public class ContractCodeGenerator(IApplicationDbContext db, IDateTime dateTime) : IContractCodeGenerator
{
public async Task<string> GenerateAsync(Contract contract, string projectCode, string supplierCode, CancellationToken ct = default)
{
// Format theo RG-001 (xem docs/forms-spec.md):
// HĐTP: {Project}/HĐTP/SOL&{Partner}/{Seq}
// HĐGK: {Project}/HĐGK/SOL&{Partner}/{Seq}
// NCC: {Project}/NCC/SOL&{Partner}/{Seq} (hoặc {Year}/NCC/SOL&{Partner}/{Seq} cho framework)
// HĐDV: {Project}/HĐDV/SOL&{Partner}/{Seq} (hoặc {Year}/HĐDV/...)
// HĐ Mua bán: dùng PO format
var typeCode = contract.Type switch
{
ContractType.HopDongThauPhu => "HĐTP",
ContractType.HopDongGiaoKhoan => "HĐGK",
ContractType.HopDongNhaCungCap => "NCC",
ContractType.HopDongDichVu => "HĐDV",
ContractType.HopDongMuaBan => "MB",
ContractType.HopDongNguyenTacNCC => "NCC",
ContractType.HopDongNguyenTacDichVu => "HĐDV",
_ => "HĐ",
};
var isFramework = contract.Type is ContractType.HopDongNguyenTacNCC or ContractType.HopDongNguyenTacDichVu;
var scope = isFramework ? dateTime.UtcNow.Year.ToString() : projectCode;
var prefix = $"{scope}/{typeCode}/SOL&{supplierCode}";
// Transaction SERIALIZABLE + UPDATE với lock
var context = (DbContext)db;
await using var tx = await context.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
try
{
var seq = await db.ContractCodeSequences.FirstOrDefaultAsync(s => s.Prefix == prefix, ct);
if (seq is null)
{
seq = new ContractCodeSequence { Prefix = prefix, LastSeq = 1, UpdatedAt = dateTime.UtcNow };
db.ContractCodeSequences.Add(seq);
}
else
{
seq.LastSeq += 1;
seq.UpdatedAt = dateTime.UtcNow;
}
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return $"{prefix}/{seq.LastSeq:D2}";
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
}
}

View File

@ -0,0 +1,120 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Infrastructure.Services;
public class ContractWorkflowService(
IApplicationDbContext db,
IContractCodeGenerator codeGenerator,
IDateTime dateTime) : IContractWorkflowService
{
// Map (from, to) → roles được phép chuyển. Xem docs/workflow-contract.md §5.
// Admin luôn bypass (check trong Handler trước khi gọi service).
private static readonly Dictionary<(ContractPhase From, ContractPhase To), string[]> Transitions = new()
{
[(ContractPhase.DangSoanThao, ContractPhase.DangGopY)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.DangSoanThao, ContractPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.DangGopY, ContractPhase.DangDamPhan)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(ContractPhase.DangGopY, ContractPhase.DangSoanThao)] = [AppRoles.ProjectManager, AppRoles.Procurement, AppRoles.CostControl, AppRoles.Finance, AppRoles.Accounting, AppRoles.Equipment],
[(ContractPhase.DangDamPhan, ContractPhase.DangInKy)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
[(ContractPhase.DangInKy, ContractPhase.DangKiemTraCCM)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
// Bypass CCM cho HĐ Chủ đầu tư — xử lý riêng trong CanTransition
[(ContractPhase.DangInKy, ContractPhase.DangTrinhKy)] = [AppRoles.Drafter, AppRoles.DeptManager, AppRoles.ProjectManager],
[(ContractPhase.DangKiemTraCCM, ContractPhase.DangTrinhKy)] = [AppRoles.CostControl],
[(ContractPhase.DangKiemTraCCM, ContractPhase.DangSoanThao)] = [AppRoles.CostControl],
[(ContractPhase.DangTrinhKy, ContractPhase.DangDongDau)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(ContractPhase.DangTrinhKy, ContractPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(ContractPhase.DangDongDau, ContractPhase.DaPhatHanh)] = [AppRoles.HrAdmin],
};
private static readonly Dictionary<ContractPhase, TimeSpan?> PhaseSla = new()
{
[ContractPhase.DangSoanThao] = TimeSpan.FromDays(7),
[ContractPhase.DangGopY] = TimeSpan.FromDays(7),
[ContractPhase.DangDamPhan] = TimeSpan.FromDays(7),
[ContractPhase.DangInKy] = TimeSpan.FromDays(1),
[ContractPhase.DangKiemTraCCM] = TimeSpan.FromDays(3),
[ContractPhase.DangTrinhKy] = TimeSpan.FromDays(1),
[ContractPhase.DangDongDau] = null,
[ContractPhase.DaPhatHanh] = null,
[ContractPhase.TuChoi] = null,
[ContractPhase.DangChon] = null,
};
public TimeSpan? GetPhaseSla(ContractPhase phase) => PhaseSla.GetValueOrDefault(phase);
public async Task TransitionAsync(
Contract contract,
ContractPhase targetPhase,
Guid? actorUserId,
IReadOnlyList<string> actorRoles,
ApprovalDecision decision,
string? comment,
CancellationToken ct = default)
{
if (contract.Phase == targetPhase)
throw new ConflictException("HĐ đã ở phase đích.");
var isAdmin = actorRoles.Contains(AppRoles.Admin);
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
if (!isAdmin && !isSystem)
{
if (!Transitions.TryGetValue((contract.Phase, targetPhase), out var allowedRoles))
throw new ForbiddenException($"Không thể chuyển {contract.Phase} → {targetPhase}.");
// Bypass rule: nếu BypassProcurementAndCCM + đang ở DangInKy → chỉ cho chuyển DangTrinhKy (skip CCM)
if (!contract.BypassProcurementAndCCM
&& contract.Phase == ContractPhase.DangInKy
&& targetPhase == ContractPhase.DangTrinhKy)
{
throw new ForbiddenException("Chỉ HĐ với Chủ đầu tư mới được bỏ qua phase CCM.");
}
if (!actorRoles.Any(r => allowedRoles.Contains(r)))
throw new ForbiddenException($"Role của bạn ({string.Join(",", actorRoles)}) không đủ quyền chuyển {contract.Phase} → {targetPhase}.");
}
var fromPhase = contract.Phase;
// Gen mã HĐ khi chuyển sang DangDongDau (BOD ký xong)
if (targetPhase == ContractPhase.DangDongDau && string.IsNullOrEmpty(contract.MaHopDong))
{
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == contract.SupplierId, ct)
?? throw new NotFoundException("Supplier", contract.SupplierId);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == contract.ProjectId, ct)
?? throw new NotFoundException("Project", contract.ProjectId);
contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct);
}
// Reset SlaWarningSent khi chuyển phase
contract.SlaWarningSent = false;
contract.Phase = targetPhase;
var sla = GetPhaseSla(targetPhase);
contract.SlaDeadline = sla is null ? null : dateTime.UtcNow.Add(sla.Value);
db.ContractApprovals.Add(new ContractApproval
{
ContractId = contract.Id,
FromPhase = fromPhase,
ToPhase = targetPhase,
ApproverUserId = actorUserId,
Decision = decision,
Comment = comment,
ApprovedAt = dateTime.UtcNow,
});
await db.SaveChangesAsync(ct);
}
}