Compare commits
3 Commits
b7a153e45a
...
7e3cfa580a
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e3cfa580a | |||
| ecf3c5945b | |||
| ee0d3608e7 |
@ -1,6 +1,26 @@
|
|||||||
# HANDOFF — Brief 5 phút cho session tiếp theo
|
# HANDOFF — Brief 5 phút cho session tiếp theo
|
||||||
|
|
||||||
**Last updated:** 2026-05-04 (Session 9+ chốt — **Chunk E-bis + Audit 2026-05 + Inbox PE + User Manual 7 file rewrite. 83 test pass. 10 commit pushed. Phase 9 còn lại = Hard blockers chờ user.**)
|
**Last updated:** 2026-05-07 (Session 10 chốt — **PE "Thao tác" 2-panel workspace + Section 5 disabled. 2 commit per-chunk pushed. 83 test pass. Phase 9 còn lại = Hard blockers chờ user.**)
|
||||||
|
|
||||||
|
## TL;DR Session 10 (07/05 — PE workspace 2-panel)
|
||||||
|
|
||||||
|
**Output session 10** — restructure leaf "Thao tác" PE từ page Create header riêng sang workspace 2-panel mirror HĐ Thầu phụ pattern:
|
||||||
|
|
||||||
|
- ✅ **Spec chốt 5 câu trước code** — Q1 Panel 2 chỉ data entry (KHÔNG Workflow/Approvals/History); Panel 1 pure picker (no inline edit/delete); Q2 mirror HĐ Thầu phụ (sticky "+ Thêm mới" + new→edit Panel 2); Q3 leaf "Danh sách" + "Duyệt" giữ 3-panel hiện tại; Q4 route mới `/workspace`; Q5 Section 5 Ý kiến 4PB disable trong workspace (nhập khi duyệt).
|
||||||
|
- ✅ **Chunk 1 fe-admin** (commit `ee0d360`) — 3 file mới: `PeListPanel.tsx` (~180 LOC pure picker reuse-able + sticky "+ Thêm mới"), `PeHeaderForm.tsx` (~210 LOC extract header form từ CreatePage), `PurchaseEvaluationWorkspacePage.tsx` (~120 LOC 2-panel `[320px_1fr]`). 3 file sửa: `PeDetailTabs.tsx` thêm prop `mode?: 'detail' \| 'workspace'` + Section 5 hint amber khi workspace + force `opinionsReadOnly`. `Layout.tsx` resolver `Pe_*_Create` → `/workspace?type=N`. `App.tsx` route mới.
|
||||||
|
- ✅ **Chunk 2 fe-user mirror** (commit `ecf3c59`) — 6 file y hệt content (rule §3.9 duplicate có chủ đích).
|
||||||
|
- ✅ **Verify**: 2 build pass + dotnet test 83 vẫn pass mỗi chunk.
|
||||||
|
|
||||||
|
**KHÔNG đụng BE / migration / schema / endpoint.** Route `/new` cũ giữ tồn tại cho deep-link "Sửa header" button.
|
||||||
|
|
||||||
|
## ⚠️ CẢNH BÁO session tiếp (Session 11+)
|
||||||
|
|
||||||
|
1. **UAT live workspace** với anh Kiệt + 2-3 user — feature mới UX khác (page Create cũ thành Dialog implicit qua sticky button + transition new→edit auto).
|
||||||
|
2. **Section 5 Ý kiến 4PB hint banner amber** ở workspace mode chỉ là text gợi ý — KHÔNG block. User vẫn thấy existing opinions read-only. Nếu UAT thấy confused → có thể đổi thành collapsible section.
|
||||||
|
3. **Mobile: workspace KHÔNG có fallback** — màn hình `<lg` (< 1024px) Panel 2 ẩn (lg:block). Cần test mobile redirect → `/purchase-evaluations/:id` nếu UAT phát hiện. Hiện chỉ admin desktop dùng workspace.
|
||||||
|
4. **Pe_*_Pending vẫn /purchase-evaluations?pendingMe=1** — leaf "Duyệt" 3-panel (giữ Panel 3 Workflow + readOnly Section 5 cho phép sign). UAT verify Drafter trình → TPB duyệt thấy Section 5 enable đúng.
|
||||||
|
5. **PE WorkflowPanel duplicate ở 2 chỗ** — leaf "Danh sách" + "Duyệt" Panel 3 (3-panel). KHÔNG có ở "Thao tác" workspace. Verify route resolver active state highlight đúng (queryMatches helper từ session 3).
|
||||||
|
6. **Sửa header trong workspace** — vẫn navigate sang `/new?id=` (button "Sửa header" trong PeDetailTabs). Chưa wire inline edit qua Dialog. Nếu UAT yêu cầu → thêm prop `onEditHeader` cho PeDetailTabs trigger Dialog reuse `PeHeaderForm`.
|
||||||
|
|
||||||
## Housekeeping today (sau Session 9)
|
## Housekeeping today (sau Session 9)
|
||||||
|
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
|
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
|
||||||
|
|
||||||
**Last updated:** 2026-05-04 (Session 9+ chốt — **Chunk E-bis + Audit 2026-05 + Inbox PE section + User Manual 7 file rewrite. 83 test pass. 10 commit pushed.**)
|
**Last updated:** 2026-05-07 (Session 10 chốt — **PE Thao tác 2-panel workspace + Section 5 disabled + 2 commit per-chunk pushed. 83 test pass.**)
|
||||||
|
|
||||||
## 📍 Phase hiện tại: **Phase 9 active — UAT** — **55 DB tables, 16 migrations, ~133 API endpoints (+2 List dept-approvals HĐ/Budget), 31 FE pages (3 panel update 2-stage + Inbox 2 section). 83 unit test pass** (54 Domain + 29 Infra: 17 codegen + 6 PE WF + 6 2-stage). 41 gotcha. 30 demo user. 6 skill. **7 file User Manual** (~86 KB compact end-user style).
|
## 📍 Phase hiện tại: **Phase 9 active — UAT** — **55 DB tables, 16 migrations, ~133 API endpoints, 32 FE pages (+1 PE workspace 2-panel). 83 unit test pass** (54 Domain + 29 Infra: 17 codegen + 6 PE WF + 6 2-stage). 41 gotcha. 30 demo user. 6 skill. **7 file User Manual** (~86 KB compact end-user style).
|
||||||
|
|
||||||
### 🌐 Production URLs
|
### 🌐 Production URLs
|
||||||
|
|
||||||
@ -61,6 +61,7 @@
|
|||||||
|
|
||||||
| Ngày | Ai | Task | Commit |
|
| Ngày | Ai | Task | Commit |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| 2026-05-07 | Claude | **PE "Thao tác" 2-panel workspace + Panel 1 read-only picker + Section 5 disabled** — User chỉ thị restructure leaf "Thao tác" (Pe_DuyetNcc_Create + Pe_DuyetNccPhuongAn_Create) từ page tạo header riêng (`/purchase-evaluations/new?type=N` — chỉ form Tên/Project/Địa điểm/Payment/Budget) sang workspace 2-panel mirror pattern HĐ Thầu phụ ContractCreatePage. 5 câu chốt spec trước code: Q1 Panel 2 KHÔNG render Workflow Panel + Approvals + History (chỉ data entry); Panel 1 = pure picker, KHÔNG inline edit/delete; Q2 mirror HĐ Thầu phụ pattern (sticky "+ Thêm mới" + Panel 2 transition new→edit form); Q3 leaf "Danh sách" + "Duyệt" giữ 3-panel hiện tại; Q4 route mới `/purchase-evaluations/workspace?type={1\|2}`; Q5 Section 5 Ý kiến 4PB disable trong workspace (vì người ta nhập khi duyệt, không phải lúc nhập liệu). 2 chunk per-commit (build + 83 test pass mỗi chunk): C1 fe-admin (3 file mới `PeListPanel.tsx` ~180 LOC pure picker reuse + `PeHeaderForm.tsx` ~210 LOC extract + `PurchaseEvaluationWorkspacePage.tsx` ~120 LOC, 3 file sửa `PeDetailTabs.tsx` thêm prop `mode?: 'detail' \| 'workspace'` + Section 5 hint banner amber + Layout.tsx resolver `Pe_*_Create`→`/workspace?type=N` + App.tsx route mới). C2 fe-user mirror y hệt 6 file (rule §3.9). KHÔNG đụng BE / migration / schema / endpoint. Route `/new` cũ giữ tồn tại cho deep-link "Sửa header" button. **Total +1142 LOC FE / 0 BE / 32 FE pages.** | `ee0d360` (C1) · `ecf3c59` (C2) · (current C3) |
|
||||||
| 2026-05-04 | Claude | **User Manual 7 file rewrite compact cho end-user** — User feedback "ko cần quá đầy đủ chi tiết, cho end-user họ làm". Setup `package.json` + `npm install docx@9.5.0` + script `npm run gen:all`. Rewrite 7 generator scripts theo style end-user friendly: BỎ field validation table 5 cột (Tên field/Kiểu/Bắt buộc/Validation/Ví dụ), BỎ error troubleshoot table 3 cột (Lỗi/Nguyên nhân/Cách xử lý), BỎ FAQ chi tiết 8 câu (giữ 1 chương "Khi gặp lỗi" 4-5 bullet), BỎ phím tắt table. GIỮ: tổng quan ngắn 1-2 câu mỗi chức năng, numbered steps đơn giản 3-7 bước, note/warn/tip chỉ critical, bullet liệt kê. 7 file: 01-Bat-dau (12.1KB cũ 21.7KB ↓44%) / 02-Hop-dong / 03-Duyet-Workflow (mention 2-stage NV/TPB Mig 16) / 04-PE-Phieu-Duyet-NCC (A/B + 4PB + tạo HĐ) / 05-Budget / 06-7-Loai-HD-Cheatsheet / admin-02-Quan-ly-Users-Roles (mention bypass review S9). Refactor user-01 dùng `_helpers.js` shared (trước có helpers inline 793 dòng, giờ ~110 dòng). Tổng output ~86KB / 7 file (cũ ~123KB ↓30%). | `16c2c9c` |
|
| 2026-05-04 | Claude | **User Manual 7 file rewrite compact cho end-user** — User feedback "ko cần quá đầy đủ chi tiết, cho end-user họ làm". Setup `package.json` + `npm install docx@9.5.0` + script `npm run gen:all`. Rewrite 7 generator scripts theo style end-user friendly: BỎ field validation table 5 cột (Tên field/Kiểu/Bắt buộc/Validation/Ví dụ), BỎ error troubleshoot table 3 cột (Lỗi/Nguyên nhân/Cách xử lý), BỎ FAQ chi tiết 8 câu (giữ 1 chương "Khi gặp lỗi" 4-5 bullet), BỎ phím tắt table. GIỮ: tổng quan ngắn 1-2 câu mỗi chức năng, numbered steps đơn giản 3-7 bước, note/warn/tip chỉ critical, bullet liệt kê. 7 file: 01-Bat-dau (12.1KB cũ 21.7KB ↓44%) / 02-Hop-dong / 03-Duyet-Workflow (mention 2-stage NV/TPB Mig 16) / 04-PE-Phieu-Duyet-NCC (A/B + 4PB + tạo HĐ) / 05-Budget / 06-7-Loai-HD-Cheatsheet / admin-02-Quan-ly-Users-Roles (mention bypass review S9). Refactor user-01 dùng `_helpers.js` shared (trước có helpers inline 793 dòng, giờ ~110 dòng). Tổng output ~86KB / 7 file (cũ ~123KB ↓30%). | `16c2c9c` |
|
||||||
| 2026-05-04 | Claude | **Optional polish — fe-user Inbox thêm section "Phiếu Duyệt NCC chờ tôi"** — User chỉ thị "tiếp tục plan tổng" → pick task không blocked. useQuery thứ 2 cho `/purchase-evaluations/inbox` (endpoint có sẵn), peRows filter theo search. Stats overdue/dueSoon đếm cả PE rows (totalValue chỉ HĐ vì PE không có giá trị). Panel 1 chia 2 section sticky header: "Hợp đồng (N)" giữ behavior cũ click → inline detail Panel 2; "Phiếu Duyệt NCC (M)" click → navigate `/purchase-evaluations/:id` page riêng (PE entity shape khác Contract, không inline). EmptyState mới: "Không có HĐ hoặc Phiếu Duyệt NCC nào chờ". Chỉ fe-user (Drafter + TPB dùng Inbox) — fe-admin defer. Build pass. | `332a90f` |
|
| 2026-05-04 | Claude | **Optional polish — fe-user Inbox thêm section "Phiếu Duyệt NCC chờ tôi"** — User chỉ thị "tiếp tục plan tổng" → pick task không blocked. useQuery thứ 2 cho `/purchase-evaluations/inbox` (endpoint có sẵn), peRows filter theo search. Stats overdue/dueSoon đếm cả PE rows (totalValue chỉ HĐ vì PE không có giá trị). Panel 1 chia 2 section sticky header: "Hợp đồng (N)" giữ behavior cũ click → inline detail Panel 2; "Phiếu Duyệt NCC (M)" click → navigate `/purchase-evaluations/:id` page riêng (PE entity shape khác Contract, không inline). EmptyState mới: "Không có HĐ hoặc Phiếu Duyệt NCC nào chờ". Chỉ fe-user (Drafter + TPB dùng Inbox) — fe-admin defer. Build pass. | `332a90f` |
|
||||||
| 2026-05-04 | Claude | **Audit định kỳ 2026-05 (combined skill + doc drift theo §6.4 + §9.4)** — Cron `solution-erp-skill-audit-monthly` empty (`No scheduled jobs`), trễ 4 ngày so schedule 2026-05-01 → manual trigger sau Session 9 close. Phase 1 cross-check counts: tests=83, migrations=16, gotchas=41, skills=6, STATUS rows=12 (<30 chưa archive) — toàn bộ khớp. Drift patches 5 file: docs/CLAUDE.md (52→55 bảng + §14 DepartmentApprovals), docs/rules.md (Phase 8→9, 77→83 test), docs/architecture.md (77→83 test 2 chỗ), .claude/skills/ef-core-migration (77→83 + ghi 6 PE 2-stage), .claude/skills/dependency-audit-erp (26+→41 bẫy). Phase 2 skill staleness: contract-workflow thêm "Phase 9 cross-ref Mig 16" block + section "Phase 9 done" (2-stage + smart reject + lock edit + bypass). KHÔNG tạo skill mới (§9.5 anti-pattern "viết skill chỉ để có thêm" — pattern 2-stage đủ generic, đã ghi đủ Domain/Service/6 test reusable). Cron recreate SKIP (CronCreate Claude SDK auto-expire 7 days, không fit monthly cadence). Audit log `docs/changelog/skill-audit-2026-05.md` (1 page). 6 file ~+30/~12 line patch, validation §6.5 OK. | `7dc0233` |
|
| 2026-05-04 | Claude | **Audit định kỳ 2026-05 (combined skill + doc drift theo §6.4 + §9.4)** — Cron `solution-erp-skill-audit-monthly` empty (`No scheduled jobs`), trễ 4 ngày so schedule 2026-05-01 → manual trigger sau Session 9 close. Phase 1 cross-check counts: tests=83, migrations=16, gotchas=41, skills=6, STATUS rows=12 (<30 chưa archive) — toàn bộ khớp. Drift patches 5 file: docs/CLAUDE.md (52→55 bảng + §14 DepartmentApprovals), docs/rules.md (Phase 8→9, 77→83 test), docs/architecture.md (77→83 test 2 chỗ), .claude/skills/ef-core-migration (77→83 + ghi 6 PE 2-stage), .claude/skills/dependency-audit-erp (26+→41 bẫy). Phase 2 skill staleness: contract-workflow thêm "Phase 9 cross-ref Mig 16" block + section "Phase 9 done" (2-stage + smart reject + lock edit + bypass). KHÔNG tạo skill mới (§9.5 anti-pattern "viết skill chỉ để có thêm" — pattern 2-stage đủ generic, đã ghi đủ Domain/Service/6 test reusable). Cron recreate SKIP (CronCreate Claude SDK auto-expire 7 days, không fit monthly cadence). Audit log `docs/changelog/skill-audit-2026-05.md` (1 page). 6 file ~+30/~12 line patch, validation §6.5 OK. | `7dc0233` |
|
||||||
|
|||||||
@ -0,0 +1,91 @@
|
|||||||
|
# Session 2026-05-07 21:00 — PE "Thao tác" 2-panel workspace
|
||||||
|
|
||||||
|
**Dev:** Claude
|
||||||
|
**Duration:** ~2h
|
||||||
|
**Base commit:** `b7a153e` (sau Session 9+ housekeeping)
|
||||||
|
|
||||||
|
## Bối cảnh
|
||||||
|
|
||||||
|
User chỉ thị Plan menu PE: leaf "Thao tác" (Pe_DuyetNcc_Create + Pe_DuyetNccPhuongAn_Create) cần restructure thành workspace 2-panel — Panel 1 list + Panel 2 đầy đủ data entry tables inline, KHÔNG còn page Create header riêng.
|
||||||
|
|
||||||
|
Reference pattern: HĐ Thầu phụ ContractCreatePage (đã có sẵn 2-panel + sticky "+ Thêm mới" + transition Panel 2 new→edit).
|
||||||
|
|
||||||
|
5 câu chốt spec trước code (tránh rework):
|
||||||
|
- **Q1** Panel 2 chỉ data entry, KHÔNG render Workflow Panel + Approvals + History (Q1 phụ: Panel 1 = pure picker, KHÔNG inline edit/delete buttons).
|
||||||
|
- **Q2** Mirror HĐ Thầu phụ pattern — sticky "+ Thêm mới" bottom Panel 1 + Panel 2 transition new (header form) → edit (full tables).
|
||||||
|
- **Q3** Leaf "Danh sách" + "Duyệt" GIỮ 3-panel hiện tại (đã có readOnly mode "Duyệt" cho Drafter trình → TPB duyệt sign Section 5).
|
||||||
|
- **Q4** Route mới `/purchase-evaluations/workspace?type={1|2}` (không reuse query flag).
|
||||||
|
- **Q5** Section 5 Ý kiến 4 PB DISABLE trong workspace (vì người ta nhập khi duyệt phiếu, không phải lúc nhập liệu data entry).
|
||||||
|
|
||||||
|
## Làm được
|
||||||
|
|
||||||
|
### Chunk 1 — fe-admin (commit `ee0d360`, +571 LOC, 6 files)
|
||||||
|
|
||||||
|
**File mới:**
|
||||||
|
- `fe-admin/src/components/pe/PeListPanel.tsx` (~180 LOC) — pure picker reuse-able. Props `typeFilter / pendingMe / selectedId / search / phase / onSelect / onSearchChange / onPhaseChange / showCreateButton / onCreate`. Render search box + phase filter + list rows (click-to-pick, NO inline edit/delete) + sticky bottom "+ Thêm mới" button (mirror HĐ Thầu phụ).
|
||||||
|
- `fe-admin/src/components/pe/PeHeaderForm.tsx` (~210 LOC) — extract header form từ `PurchaseEvaluationCreatePage`. Props `editId / defaultType / onSaved (id, type) / onCancel`. Fetch projects + eligible budgets (Phase=DaDuyet + same project), POST/PUT `/purchase-evaluations`. Caller decide navigation sau save.
|
||||||
|
- `fe-admin/src/pages/pe/PurchaseEvaluationWorkspacePage.tsx` (~120 LOC) — 2-panel `[320px_1fr]`. Panel 1 `<PeListPanel showCreateButton onCreate={() => setParams({ mode: 'new' })}>`. Panel 2 3-state: empty | mode=new `<PeHeaderForm onSaved={(id, t) => setParams({ id, mode: null, type: String(t) })}>` | edit `<PeDetailTabs evaluation={detail} mode="workspace">`. URL state via `useSearchParams` (replace cho `q`, push cho `id/mode`).
|
||||||
|
|
||||||
|
**File sửa:**
|
||||||
|
- `fe-admin/src/components/pe/PeDetailTabs.tsx` — thêm prop `mode?: 'detail' | 'workspace'` default `'detail'`. Khi mode='workspace': force `opinionsReadOnly = readOnly || mode === 'workspace'` (Section 5 OpinionBox luôn readOnly) + render hint banner amber "Ý kiến + chữ ký nhập khi duyệt phiếu — vào menu 'Duyệt' để ký". Section 1-4 không đổi. Workflow Panel + Approvals + History KHÔNG nằm trong PeDetailTabs (callsite control — workspace skip render `<PeWorkflowPanel>` luôn).
|
||||||
|
- `fe-admin/src/components/Layout.tsx` — resolver `Pe_*_Create`: `/purchase-evaluations/new?type=N` → `/purchase-evaluations/workspace?type=N`. Comment ghi rõ Q4 chốt + route `/new` giữ tồn tại cho deep-link "Sửa header" button.
|
||||||
|
- `fe-admin/src/App.tsx` — import `PurchaseEvaluationWorkspacePage` + thêm route `/purchase-evaluations/workspace`. Route `/new` + `/:id` giữ nguyên.
|
||||||
|
|
||||||
|
### Chunk 2 — fe-user mirror (commit `ecf3c59`, +571 LOC, 6 files)
|
||||||
|
|
||||||
|
Y hệt Chunk 1 (rule §3.9 duplicate có chủ đích — copy + sync tay khi breaking). Content 100% identical 3 file mới (chỉ path khác `fe-user/src/...`). 3 file sửa cùng diff.
|
||||||
|
|
||||||
|
### Chunk 3 — Docs (commit current, ~150 dòng)
|
||||||
|
|
||||||
|
- `docs/STATUS.md` — header "Last updated" + "Phase hiện tại" cập nhật count FE pages 31→32. Recently Done thêm 1 row tại đầu bảng (rule §6.5 KEEP — không cắt narrative cũ, chỉ thêm).
|
||||||
|
- `docs/HANDOFF.md` — TL;DR Session 10 thêm trên cùng + 6 bullet cảnh báo session 11+ + giữ nguyên Session 9 narrative.
|
||||||
|
- `docs/changelog/sessions/2026-05-07-2100-pe-workspace-2panel.md` — file này.
|
||||||
|
|
||||||
|
## E2E verified
|
||||||
|
|
||||||
|
- `npm run build` (fe-admin) pass — 16.25s, 1922 modules.
|
||||||
|
- `npm run build` (fe-user) pass — 6.80s, 1904 modules.
|
||||||
|
- `dotnet test SolutionErp.slnx` — 83 pass / 0 fail (54 Domain / 29 Infra), 5s.
|
||||||
|
- Manual smoke (chưa chạy live UAT — defer cho user thử):
|
||||||
|
- Click menu "Quy trình chọn Thầu phụ - NCC" → "Duyệt NCC" → "Thao tác" → workspace 2-panel ra.
|
||||||
|
- Bấm "+ Thêm mới" sticky bottom → Panel 2 đổi sang `<PeHeaderForm>`. Save → Panel 1 refresh, phiếu mới highlighted, Panel 2 đổi sang `<PeDetailTabs mode="workspace">` đầy đủ 5 section.
|
||||||
|
- Section 5: hint banner amber + 4 OpinionBox readOnly (text input không hiện, button "Lưu & Ký" không hiện).
|
||||||
|
- Click phiếu khác Panel 1 → Panel 2 fetch + render mới.
|
||||||
|
- Click "Đóng" → Panel 2 về empty state.
|
||||||
|
|
||||||
|
## Bug gặp + fix
|
||||||
|
|
||||||
|
| Bug | Fix |
|
||||||
|
|---|---|
|
||||||
|
| (none — pattern HĐ Thầu phụ extract clean) | — |
|
||||||
|
|
||||||
|
## Docs updates
|
||||||
|
|
||||||
|
- STATUS.md (1 row Recently Done + count FE pages)
|
||||||
|
- HANDOFF.md (TL;DR Session 10 prepend + cảnh báo)
|
||||||
|
- session log (file này)
|
||||||
|
- KHÔNG update skill (rule §9.5 anti-pattern "viết skill chỉ để có thêm" — không drift đáng audit, chỉ FE refactor)
|
||||||
|
- KHÔNG update gotchas (không phát sinh bẫy mới)
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
Phase 9 active. Hard blockers user/ops vẫn pending (UAT thật / SMTP / Rotate creds / SQL backup). Workspace là optional polish item từ HANDOFF Session 9 — đã đóng. Còn lại trong Phase 9:
|
||||||
|
|
||||||
|
- Test live workspace với UAT user — đặc biệt Section 5 hint clarity + transition new→edit.
|
||||||
|
- Optional polish chưa làm: Budget MaNganSach atomic seq · Budget versioned WF · Payment terms tách field · Auto-map PE→Contract Details · Matrix Quotes bulk paste Excel.
|
||||||
|
- Tests Phase 3-5 vẫn pending (rule §7 — làm khi gặp bug recurring).
|
||||||
|
|
||||||
|
Cron audit kế: 2026-06-01 (~25 ngày).
|
||||||
|
|
||||||
|
## Thông số cumulative (sau Session 10)
|
||||||
|
|
||||||
|
| | Trước S10 | Sau S10 |
|
||||||
|
|---|---:|---:|
|
||||||
|
| BE LOC | ~14400 | ~14400 (no change) |
|
||||||
|
| API endpoints | ~133 | ~133 (no change) |
|
||||||
|
| Migrations | 16 | 16 |
|
||||||
|
| FE pages | 31 | **32** (+1 PurchaseEvaluationWorkspacePage) |
|
||||||
|
| FE components mới | — | +2 (`PeListPanel` + `PeHeaderForm` reuse-able) |
|
||||||
|
| Tests | 83 | 83 (no change — FE refactor không cần BE test) |
|
||||||
|
| Docs | ~52 | ~53 (+session log này) |
|
||||||
|
| Commits | (after S9+) | +3 (C1 + C2 + C3) |
|
||||||
@ -21,6 +21,7 @@ import { ReportsPage } from '@/pages/ReportsPage'
|
|||||||
import { UsersPage } from '@/pages/system/UsersPage'
|
import { UsersPage } from '@/pages/system/UsersPage'
|
||||||
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
|
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
|
||||||
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
|
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
|
||||||
|
import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage'
|
||||||
import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage'
|
import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage'
|
||||||
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
|
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
|
||||||
|
|
||||||
@ -55,6 +56,7 @@ function App() {
|
|||||||
<Route path="/contracts/new" element={<ContractCreatePage />} />
|
<Route path="/contracts/new" element={<ContractCreatePage />} />
|
||||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||||
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
|
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
|
||||||
|
<Route path="/purchase-evaluations/workspace" element={<PurchaseEvaluationWorkspacePage />} />
|
||||||
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
|
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
|
||||||
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
|
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
|
||||||
<Route path="/budgets" element={<BudgetsListPage />} />
|
<Route path="/budgets" element={<BudgetsListPage />} />
|
||||||
|
|||||||
@ -78,7 +78,10 @@ function resolvePath(key: string): string | null {
|
|||||||
const typeInt = PE_CODE_TO_INT[code]
|
const typeInt = PE_CODE_TO_INT[code]
|
||||||
if (!typeInt) return null
|
if (!typeInt) return null
|
||||||
if (action === 'List') return `/purchase-evaluations?type=${typeInt}`
|
if (action === 'List') return `/purchase-evaluations?type=${typeInt}`
|
||||||
if (action === 'Create') return `/purchase-evaluations/new?type=${typeInt}`
|
// "Thao tác" leaf → workspace 2-panel (Q4 2026-05-07): pick + create + sửa
|
||||||
|
// tables inline. Header-only `/new` page giữ tồn tại cho deep-link cũ
|
||||||
|
// (PeDetailTabs "Sửa header" button vẫn navigate sang đó).
|
||||||
|
if (action === 'Create') return `/purchase-evaluations/workspace?type=${typeInt}`
|
||||||
if (action === 'Pending') return `/purchase-evaluations?type=${typeInt}&pendingMe=1`
|
if (action === 'Pending') return `/purchase-evaluations?type=${typeInt}&pendingMe=1`
|
||||||
}
|
}
|
||||||
// PE workflow admin leaf: PeWf_<Code> → /system/pe-workflows/<code>
|
// PE workflow admin leaf: PeWf_<Code> → /system/pe-workflows/<code>
|
||||||
|
|||||||
@ -38,20 +38,32 @@ const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
|||||||
|
|
||||||
// Main detail content — flat render 3 section không tabs.
|
// Main detail content — flat render 3 section không tabs.
|
||||||
// Tên giữ PeDetailTabs để không break callsite (rename gây churn).
|
// Tên giữ PeDetailTabs để không break callsite (rename gây churn).
|
||||||
|
//
|
||||||
|
// `mode` (2026-05-07):
|
||||||
|
// - 'detail' (default): full UX — Section 5 Ý kiến 4PB editable theo readOnly.
|
||||||
|
// Dùng ở leaf "Danh sách" + "Duyệt" (3-panel pages).
|
||||||
|
// - 'workspace': dùng ở leaf "Thao tác" (2-panel workspace). Section 5 LUÔN
|
||||||
|
// disabled (Q5 user — ý kiến nhập khi duyệt, không phải workspace nhập liệu).
|
||||||
|
// Workflow Panel + Approvals + History KHÔNG render trong PeDetailTabs (luôn
|
||||||
|
// ở caller PeWorkflowPanel — workspace caller skip render Panel 3 hoàn toàn).
|
||||||
export function PeDetailTabs({
|
export function PeDetailTabs({
|
||||||
evaluation,
|
evaluation,
|
||||||
onBack,
|
onBack,
|
||||||
onDelete,
|
onDelete,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
mode = 'detail',
|
||||||
}: {
|
}: {
|
||||||
evaluation: PeDetailBundle
|
evaluation: PeDetailBundle
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
/** Menu "Duyệt" (pendingMe=1) — ẩn mọi action thêm/sửa/xóa, chỉ xem + duyệt phase. */
|
/** Menu "Duyệt" (pendingMe=1) — ẩn mọi action thêm/sửa/xóa, chỉ xem + duyệt phase. */
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
/** 'workspace' = Section 5 LUÔN disabled (ý kiến nhập ở leaf Duyệt). */
|
||||||
|
mode?: 'detail' | 'workspace'
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao
|
const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
const opinionsReadOnly = readOnly || mode === 'workspace'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
@ -112,7 +124,12 @@ export function PeDetailTabs({
|
|||||||
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
||||||
</Section>
|
</Section>
|
||||||
<Section title="5. Ý kiến 4 phòng ban (sign-off)">
|
<Section title="5. Ý kiến 4 phòng ban (sign-off)">
|
||||||
<DepartmentOpinionsSection ev={evaluation} readOnly={readOnly} />
|
{mode === 'workspace' && (
|
||||||
|
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
|
||||||
|
Ý kiến + chữ ký nhập khi duyệt phiếu — vào menu “Duyệt” để ký.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
223
fe-admin/src/components/pe/PeHeaderForm.tsx
Normal file
223
fe-admin/src/components/pe/PeHeaderForm.tsx
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
// Header form cho phiếu Duyệt NCC — tách từ PurchaseEvaluationCreatePage để
|
||||||
|
// reuse trong Workspace mode "new". Sửa header sau khi tạo vẫn redirect về
|
||||||
|
// page Create cũ (`/purchase-evaluations/new?id=`) — workspace KHÔNG re-edit
|
||||||
|
// header.
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
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 {
|
||||||
|
PurchaseEvaluationType,
|
||||||
|
PurchaseEvaluationTypeLabel,
|
||||||
|
type PeDetailBundle,
|
||||||
|
} from '@/types/purchaseEvaluation'
|
||||||
|
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
|
||||||
|
import type { Paged, Project } from '@/types/master'
|
||||||
|
|
||||||
|
export function PeHeaderForm({
|
||||||
|
editId,
|
||||||
|
defaultType,
|
||||||
|
onSaved,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
editId?: string | null
|
||||||
|
defaultType?: number
|
||||||
|
/** Gọi sau khi save thành công với (newId, type). Caller decide navigation. */
|
||||||
|
onSaved: (id: string, type: number) => void
|
||||||
|
onCancel?: () => void
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const initialType = defaultType ?? PurchaseEvaluationType.DuyetNcc
|
||||||
|
|
||||||
|
const projects = useQuery({
|
||||||
|
queryKey: ['all-projects'],
|
||||||
|
queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
|
||||||
|
})
|
||||||
|
const existing = useQuery({
|
||||||
|
queryKey: ['pe-detail', editId],
|
||||||
|
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${editId}`)).data,
|
||||||
|
enabled: !!editId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
type: initialType as number,
|
||||||
|
tenGoiThau: '',
|
||||||
|
projectId: '',
|
||||||
|
diaDiem: '',
|
||||||
|
moTa: '',
|
||||||
|
paymentTerms: '',
|
||||||
|
budgetId: '' as string,
|
||||||
|
})
|
||||||
|
|
||||||
|
const eligibleBudgets = useQuery({
|
||||||
|
queryKey: ['eligible-budgets', form.projectId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<Paged<BudgetListItem>>('/budgets', {
|
||||||
|
params: { pageSize: 100, projectId: form.projectId, phase: BudgetPhase.DaDuyet },
|
||||||
|
})
|
||||||
|
return res.data.items
|
||||||
|
},
|
||||||
|
enabled: !!form.projectId,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (existing.data) {
|
||||||
|
setForm({
|
||||||
|
type: existing.data.type,
|
||||||
|
tenGoiThau: existing.data.tenGoiThau,
|
||||||
|
projectId: existing.data.projectId,
|
||||||
|
diaDiem: existing.data.diaDiem ?? '',
|
||||||
|
moTa: existing.data.moTa ?? '',
|
||||||
|
paymentTerms: existing.data.paymentTerms ?? '',
|
||||||
|
budgetId: existing.data.budgetId ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [existing.data])
|
||||||
|
|
||||||
|
const mut = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (editId) {
|
||||||
|
return api.put(`/purchase-evaluations/${editId}`, {
|
||||||
|
id: editId,
|
||||||
|
tenGoiThau: form.tenGoiThau,
|
||||||
|
diaDiem: form.diaDiem || null,
|
||||||
|
moTa: form.moTa || null,
|
||||||
|
paymentTerms: form.paymentTerms || null,
|
||||||
|
budgetId: form.budgetId || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return api.post<{ id: string }>('/purchase-evaluations', {
|
||||||
|
type: form.type,
|
||||||
|
tenGoiThau: form.tenGoiThau,
|
||||||
|
projectId: form.projectId,
|
||||||
|
diaDiem: form.diaDiem || null,
|
||||||
|
moTa: form.moTa || null,
|
||||||
|
paymentTerms: form.paymentTerms || null,
|
||||||
|
budgetId: form.budgetId || null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: res => {
|
||||||
|
toast.success(editId ? 'Đã lưu.' : 'Đã tạo phiếu.')
|
||||||
|
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||||
|
const id = editId ?? (res as { data: { id: string } }).data.id
|
||||||
|
onSaved(id, form.type)
|
||||||
|
},
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<header>
|
||||||
|
<h2 className="text-base font-semibold tracking-tight text-slate-900">
|
||||||
|
{editId ? 'Sửa header phiếu' : 'Tạo phiếu Duyệt NCC mới'}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-[12px] text-slate-500">
|
||||||
|
{editId
|
||||||
|
? 'Chỉ sửa các field thông tin chung — NCC + báo giá + ý kiến nhập ở Panel chi tiết.'
|
||||||
|
: 'Tạo header trước, sau đó nhập NCC + Báo giá + Hạng mục ở Panel chi tiết.'}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Loại quy trình</Label>
|
||||||
|
<Select
|
||||||
|
value={form.type}
|
||||||
|
disabled={!!editId}
|
||||||
|
onChange={e => setForm({ ...form, type: Number(e.target.value) })}
|
||||||
|
>
|
||||||
|
{Object.values(PurchaseEvaluationType).map(t => (
|
||||||
|
<option key={t} value={t}>{PurchaseEvaluationTypeLabel[t]}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Tên gói thầu *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.tenGoiThau}
|
||||||
|
onChange={e => setForm({ ...form, tenGoiThau: e.target.value })}
|
||||||
|
placeholder="vd Cung cấp bê tông"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Dự án *</Label>
|
||||||
|
<Select
|
||||||
|
value={form.projectId}
|
||||||
|
disabled={!!editId}
|
||||||
|
onChange={e => setForm({ ...form, projectId: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">-- Chọn --</option>
|
||||||
|
{projects.data?.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{p.code} — {p.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Ngân sách (đối chiếu chi phí)</Label>
|
||||||
|
<Select
|
||||||
|
value={form.budgetId}
|
||||||
|
disabled={!form.projectId}
|
||||||
|
onChange={e => setForm({ ...form, budgetId: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">— (không link)</option>
|
||||||
|
{eligibleBudgets.data?.map(b => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">
|
||||||
|
{!form.projectId
|
||||||
|
? 'Chọn dự án trước để xem ngân sách khả dụng.'
|
||||||
|
: eligibleBudgets.data && eligibleBudgets.data.length === 0
|
||||||
|
? 'Dự án này chưa có ngân sách đã duyệt.'
|
||||||
|
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Địa điểm</Label>
|
||||||
|
<Input
|
||||||
|
value={form.diaDiem}
|
||||||
|
onChange={e => setForm({ ...form, diaDiem: e.target.value })}
|
||||||
|
placeholder="Lô K, KCN Lộc An - Bình Sơn..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Mô tả</Label>
|
||||||
|
<Textarea rows={3} value={form.moTa} onChange={e => setForm({ ...form, moTa: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Điều khoản thanh toán (JSON hoặc text)</Label>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
value={form.paymentTerms}
|
||||||
|
onChange={e => setForm({ ...form, paymentTerms: e.target.value })}
|
||||||
|
placeholder='{"tamUng":"10%","thanhToanTam":"100% W.done","quyetToan":"Final Account","baoHanh":"5%"}'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{onCancel && (
|
||||||
|
<Button variant="ghost" onClick={onCancel}>Hủy</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => mut.mutate()}
|
||||||
|
disabled={!form.tenGoiThau || !form.projectId || mut.isPending}
|
||||||
|
>
|
||||||
|
{editId ? 'Lưu' : 'Tạo phiếu'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
184
fe-admin/src/components/pe/PeListPanel.tsx
Normal file
184
fe-admin/src/components/pe/PeListPanel.tsx
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
// Pure picker panel cho workspace 2-panel "Thao tác" (Pe_*_Create leaf).
|
||||||
|
// KHÔNG có inline Edit/Delete (per Q1 user 2026-05-07): chỉ click để pick, +
|
||||||
|
// optional sticky bottom "+ Thêm mới" button khi showCreateButton=true.
|
||||||
|
//
|
||||||
|
// Reuse-able: caller quản URL state qua props (search/phase/typeFilter), panel
|
||||||
|
// chỉ render + invoke callbacks. Pendingme vẫn truyền được nếu cần dùng cho
|
||||||
|
// inbox view khác (hiện chỉ workspace dùng pendingMe=false).
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ClipboardCheck, Plus, Search } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Input } from '@/components/ui/Input'
|
||||||
|
import { Select } from '@/components/ui/Select'
|
||||||
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import type { Paged } from '@/types/master'
|
||||||
|
import {
|
||||||
|
PurchaseEvaluationPhase,
|
||||||
|
PurchaseEvaluationPhaseColor,
|
||||||
|
PurchaseEvaluationPhaseLabel,
|
||||||
|
PurchaseEvaluationTypeLabel,
|
||||||
|
type PeListItem,
|
||||||
|
} from '@/types/purchaseEvaluation'
|
||||||
|
|
||||||
|
export function PeListPanel({
|
||||||
|
typeFilter,
|
||||||
|
pendingMe = false,
|
||||||
|
selectedId,
|
||||||
|
search,
|
||||||
|
phase,
|
||||||
|
onSelect,
|
||||||
|
onSearchChange,
|
||||||
|
onPhaseChange,
|
||||||
|
showCreateButton = false,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
typeFilter: number | null
|
||||||
|
pendingMe?: boolean
|
||||||
|
selectedId: string | null
|
||||||
|
search: string
|
||||||
|
phase: string
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
onSearchChange: (q: string) => void
|
||||||
|
onPhaseChange: (p: string) => void
|
||||||
|
showCreateButton?: boolean
|
||||||
|
onCreate?: () => void
|
||||||
|
}) {
|
||||||
|
const list = useQuery({
|
||||||
|
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase }],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (pendingMe) {
|
||||||
|
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
|
||||||
|
params: { type: typeFilter ?? undefined },
|
||||||
|
})
|
||||||
|
return { items: res.data, total: res.data.length, page: 1, pageSize: res.data.length }
|
||||||
|
}
|
||||||
|
const res = await api.get<Paged<PeListItem>>('/purchase-evaluations', {
|
||||||
|
params: {
|
||||||
|
pageSize: 50,
|
||||||
|
search: search || undefined,
|
||||||
|
type: typeFilter ?? undefined,
|
||||||
|
phase: phase || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = list.data?.items ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
|
||||||
|
{/* Header — count + filter */}
|
||||||
|
<div className="space-y-2 border-b border-slate-200 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Danh sách phiếu
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||||
|
{list.data?.total ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={e => onSearchChange(e.target.value)}
|
||||||
|
placeholder="Tìm mã / tên gói thầu / dự án…"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={phase} onChange={e => onPhaseChange(e.target.value)}>
|
||||||
|
<option value="">Tất cả phase</option>
|
||||||
|
{Object.values(PurchaseEvaluationPhase).map(p => (
|
||||||
|
<option key={p} value={p}>{PurchaseEvaluationPhaseLabel[p]}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List body */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{list.isLoading && (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!list.isLoading && rows.length === 0 && (
|
||||||
|
<div className="p-6">
|
||||||
|
<EmptyState
|
||||||
|
icon={ClipboardCheck}
|
||||||
|
title="Chưa có phiếu"
|
||||||
|
description={
|
||||||
|
showCreateButton
|
||||||
|
? 'Bấm + Thêm mới ở dưới để tạo phiếu đầu tiên.'
|
||||||
|
: 'Chưa có phiếu nào khớp bộ lọc.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{rows.map(p => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect(p.id)}
|
||||||
|
className={cn(
|
||||||
|
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
|
||||||
|
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||||
|
<span className="font-mono">{p.maPhieu ?? '—'}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="truncate">{p.projectName}</span>
|
||||||
|
</div>
|
||||||
|
{p.selectedSupplierName && (
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-emerald-600">
|
||||||
|
✓ {p.selectedSupplierName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
PurchaseEvaluationPhaseColor[p.phase],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{PurchaseEvaluationPhaseLabel[p.phase]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
|
||||||
|
{PurchaseEvaluationTypeLabel[p.type]}
|
||||||
|
</span>
|
||||||
|
<SlaTimer deadline={p.slaDeadline} createdAt={p.createdAt} />
|
||||||
|
</div>
|
||||||
|
{p.contractId && (
|
||||||
|
<div className="mt-1 text-[10px] text-brand-600">✓ Đã tạo HĐ</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sticky bottom — "+ Thêm mới" button (mirror HĐ Thầu phụ pattern) */}
|
||||||
|
{showCreateButton && (
|
||||||
|
<div className="border-t border-slate-200 bg-white p-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => onCreate?.()}
|
||||||
|
className="w-full justify-center gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Thêm mới
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
fe-admin/src/pages/pe/PurchaseEvaluationWorkspacePage.tsx
Normal file
140
fe-admin/src/pages/pe/PurchaseEvaluationWorkspacePage.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
// Workspace 2-panel cho leaf "Thao tác" Pe_*_Create (Type A=DuyetNcc / B=
|
||||||
|
// DuyetNccPhuongAn). Pattern mirror HĐ Thầu phụ ContractCreatePage:
|
||||||
|
// Panel 1 (320px): list pure picker (read-only, không edit/delete) + sticky
|
||||||
|
// "+ Thêm mới" bottom button (Q1 user 2026-05-07).
|
||||||
|
// Panel 2 (1fr): empty state · mode=new <PeHeaderForm> · else
|
||||||
|
// <PeDetailTabs mode="workspace"> (5 section + Section 5
|
||||||
|
// Ý kiến 4PB DISABLED — Q5: nhập ở leaf "Duyệt").
|
||||||
|
//
|
||||||
|
// URL: /purchase-evaluations/workspace?type={1|2}[&id=...][&mode=new][&q=][&phase=]
|
||||||
|
// Workflow Panel + Approvals + History KHÔNG render ở workspace (Q1 — chỉ
|
||||||
|
// hiện ở leaf Danh sách + Duyệt vẫn 3-panel).
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { ClipboardCheck } from 'lucide-react'
|
||||||
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
|
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
|
||||||
|
import { PeListPanel } from '@/components/pe/PeListPanel'
|
||||||
|
import { PeHeaderForm } from '@/components/pe/PeHeaderForm'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
|
import {
|
||||||
|
PurchaseEvaluationType,
|
||||||
|
PurchaseEvaluationTypeLabel,
|
||||||
|
type PeDetailBundle,
|
||||||
|
} from '@/types/purchaseEvaluation'
|
||||||
|
|
||||||
|
export function PurchaseEvaluationWorkspacePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [sp, setSp] = useSearchParams()
|
||||||
|
const typeFilter = sp.get('type') ? Number(sp.get('type')) : PurchaseEvaluationType.DuyetNcc
|
||||||
|
const search = sp.get('q') ?? ''
|
||||||
|
const phase = sp.get('phase') ?? ''
|
||||||
|
const selectedId = sp.get('id')
|
||||||
|
const mode = sp.get('mode') // 'new' | null
|
||||||
|
|
||||||
|
const detail = useQuery({
|
||||||
|
queryKey: ['pe-detail', selectedId],
|
||||||
|
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${selectedId}`)).data,
|
||||||
|
enabled: !!selectedId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Đã xóa phiếu.')
|
||||||
|
setParams({ id: null })
|
||||||
|
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||||
|
},
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
function setParams(updates: Record<string, string | null>) {
|
||||||
|
const next = new URLSearchParams(sp)
|
||||||
|
for (const [k, v] of Object.entries(updates)) {
|
||||||
|
if (v == null || v === '') next.delete(k)
|
||||||
|
else next.set(k, v)
|
||||||
|
}
|
||||||
|
// Search input gõ liên tục → replace (không spam history); pick/mode → push
|
||||||
|
const replace = Object.keys(updates).length === 1 && updates.q !== undefined
|
||||||
|
setSp(next, { replace })
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerTitle = `${PurchaseEvaluationTypeLabel[typeFilter]} — Thao tác`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||||
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-5 w-5 text-slate-500" />
|
||||||
|
<h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-slate-500">
|
||||||
|
Workspace 2-panel — Workflow + Duyệt ở menu “Duyệt”.
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[320px_1fr]">
|
||||||
|
{/* Panel 1: List pure picker + sticky create */}
|
||||||
|
<PeListPanel
|
||||||
|
typeFilter={typeFilter}
|
||||||
|
selectedId={selectedId}
|
||||||
|
search={search}
|
||||||
|
phase={phase}
|
||||||
|
onSelect={id => setParams({ id, mode: null })}
|
||||||
|
onSearchChange={q => setParams({ q })}
|
||||||
|
onPhaseChange={p => setParams({ phase: p })}
|
||||||
|
showCreateButton
|
||||||
|
onCreate={() => setParams({ mode: 'new', id: null })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panel 2: Empty | Header form | Detail tabs (workspace mode) */}
|
||||||
|
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
|
||||||
|
{/* Empty: chưa pick + chưa create */}
|
||||||
|
{!selectedId && mode !== 'new' && (
|
||||||
|
<EmptyState
|
||||||
|
icon={ClipboardCheck}
|
||||||
|
title="Chọn phiếu hoặc tạo mới"
|
||||||
|
description='Chọn 1 phiếu ở danh sách trái để nhập liệu, hoặc bấm "+ Thêm mới" ở dưới.'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mode "new": header form */}
|
||||||
|
{mode === 'new' && (
|
||||||
|
<PeHeaderForm
|
||||||
|
defaultType={typeFilter}
|
||||||
|
onSaved={(newId, t) => setParams({ id: newId, mode: null, type: String(t) })}
|
||||||
|
onCancel={() => setParams({ mode: null })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mode "edit": detail tabs (workspace = no workflow + Section 5 disabled) */}
|
||||||
|
{selectedId && detail.isLoading && (
|
||||||
|
<div className="text-sm text-slate-500">Đang tải…</div>
|
||||||
|
)}
|
||||||
|
{selectedId && detail.data && (
|
||||||
|
<PeDetailTabs
|
||||||
|
evaluation={detail.data}
|
||||||
|
onBack={() => setParams({ id: null })}
|
||||||
|
onDelete={() => del.mutate(detail.data!.id)}
|
||||||
|
mode="workspace"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile fallback: nếu không lg, redirect về detail page */}
|
||||||
|
{selectedId && (
|
||||||
|
<div className="lg:hidden">
|
||||||
|
{/* Quick UX: tap row khi mobile sẽ navigate fullpage detail */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/purchase-evaluations/${selectedId}`)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
|
|||||||
import { MyContractsPage } from '@/pages/contracts/MyContractsPage'
|
import { MyContractsPage } from '@/pages/contracts/MyContractsPage'
|
||||||
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
|
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
|
||||||
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
|
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
|
||||||
|
import { PurchaseEvaluationWorkspacePage } from '@/pages/pe/PurchaseEvaluationWorkspacePage'
|
||||||
import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage'
|
import { BudgetsListPage, BudgetDetailPage } from '@/pages/budgets/BudgetsListPage'
|
||||||
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
|
import { BudgetCreatePage } from '@/pages/budgets/BudgetCreatePage'
|
||||||
|
|
||||||
@ -33,6 +34,7 @@ function App() {
|
|||||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||||
<Route path="/my-contracts" element={<MyContractsPage />} />
|
<Route path="/my-contracts" element={<MyContractsPage />} />
|
||||||
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
|
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
|
||||||
|
<Route path="/purchase-evaluations/workspace" element={<PurchaseEvaluationWorkspacePage />} />
|
||||||
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
|
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
|
||||||
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
|
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
|
||||||
<Route path="/budgets" element={<BudgetsListPage />} />
|
<Route path="/budgets" element={<BudgetsListPage />} />
|
||||||
|
|||||||
@ -81,7 +81,10 @@ function resolvePath(key: string): string | null {
|
|||||||
const typeInt = PE_CODE_TO_INT[code]
|
const typeInt = PE_CODE_TO_INT[code]
|
||||||
if (!typeInt) return null
|
if (!typeInt) return null
|
||||||
if (action === 'List') return `/purchase-evaluations?type=${typeInt}`
|
if (action === 'List') return `/purchase-evaluations?type=${typeInt}`
|
||||||
if (action === 'Create') return `/purchase-evaluations/new?type=${typeInt}`
|
// "Thao tác" leaf → workspace 2-panel (Q4 2026-05-07): pick + create + sửa
|
||||||
|
// tables inline. Header-only `/new` page giữ tồn tại cho deep-link cũ
|
||||||
|
// (PeDetailTabs "Sửa header" button vẫn navigate sang đó).
|
||||||
|
if (action === 'Create') return `/purchase-evaluations/workspace?type=${typeInt}`
|
||||||
if (action === 'Pending') return `/purchase-evaluations?type=${typeInt}&pendingMe=1`
|
if (action === 'Pending') return `/purchase-evaluations?type=${typeInt}&pendingMe=1`
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|||||||
@ -38,20 +38,32 @@ const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
|||||||
|
|
||||||
// Main detail content — flat render 3 section không tabs.
|
// Main detail content — flat render 3 section không tabs.
|
||||||
// Tên giữ PeDetailTabs để không break callsite (rename gây churn).
|
// Tên giữ PeDetailTabs để không break callsite (rename gây churn).
|
||||||
|
//
|
||||||
|
// `mode` (2026-05-07):
|
||||||
|
// - 'detail' (default): full UX — Section 5 Ý kiến 4PB editable theo readOnly.
|
||||||
|
// Dùng ở leaf "Danh sách" + "Duyệt" (3-panel pages).
|
||||||
|
// - 'workspace': dùng ở leaf "Thao tác" (2-panel workspace). Section 5 LUÔN
|
||||||
|
// disabled (Q5 user — ý kiến nhập khi duyệt, không phải workspace nhập liệu).
|
||||||
|
// Workflow Panel + Approvals + History KHÔNG render trong PeDetailTabs (luôn
|
||||||
|
// ở caller PeWorkflowPanel — workspace caller skip render Panel 3 hoàn toàn).
|
||||||
export function PeDetailTabs({
|
export function PeDetailTabs({
|
||||||
evaluation,
|
evaluation,
|
||||||
onBack,
|
onBack,
|
||||||
onDelete,
|
onDelete,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
mode = 'detail',
|
||||||
}: {
|
}: {
|
||||||
evaluation: PeDetailBundle
|
evaluation: PeDetailBundle
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
/** Menu "Duyệt" (pendingMe=1) — ẩn mọi action thêm/sửa/xóa, chỉ xem + duyệt phase. */
|
/** Menu "Duyệt" (pendingMe=1) — ẩn mọi action thêm/sửa/xóa, chỉ xem + duyệt phase. */
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
/** 'workspace' = Section 5 LUÔN disabled (ý kiến nhập ở leaf Duyệt). */
|
||||||
|
mode?: 'detail' | 'workspace'
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao
|
const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
const opinionsReadOnly = readOnly || mode === 'workspace'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||||
@ -112,7 +124,12 @@ export function PeDetailTabs({
|
|||||||
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
||||||
</Section>
|
</Section>
|
||||||
<Section title="5. Ý kiến 4 phòng ban (sign-off)">
|
<Section title="5. Ý kiến 4 phòng ban (sign-off)">
|
||||||
<DepartmentOpinionsSection ev={evaluation} readOnly={readOnly} />
|
{mode === 'workspace' && (
|
||||||
|
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-800">
|
||||||
|
Ý kiến + chữ ký nhập khi duyệt phiếu — vào menu “Duyệt” để ký.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
223
fe-user/src/components/pe/PeHeaderForm.tsx
Normal file
223
fe-user/src/components/pe/PeHeaderForm.tsx
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
// Header form cho phiếu Duyệt NCC — tách từ PurchaseEvaluationCreatePage để
|
||||||
|
// reuse trong Workspace mode "new". Sửa header sau khi tạo vẫn redirect về
|
||||||
|
// page Create cũ (`/purchase-evaluations/new?id=`) — workspace KHÔNG re-edit
|
||||||
|
// header.
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
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 {
|
||||||
|
PurchaseEvaluationType,
|
||||||
|
PurchaseEvaluationTypeLabel,
|
||||||
|
type PeDetailBundle,
|
||||||
|
} from '@/types/purchaseEvaluation'
|
||||||
|
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
|
||||||
|
import type { Paged, Project } from '@/types/master'
|
||||||
|
|
||||||
|
export function PeHeaderForm({
|
||||||
|
editId,
|
||||||
|
defaultType,
|
||||||
|
onSaved,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
editId?: string | null
|
||||||
|
defaultType?: number
|
||||||
|
/** Gọi sau khi save thành công với (newId, type). Caller decide navigation. */
|
||||||
|
onSaved: (id: string, type: number) => void
|
||||||
|
onCancel?: () => void
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const initialType = defaultType ?? PurchaseEvaluationType.DuyetNcc
|
||||||
|
|
||||||
|
const projects = useQuery({
|
||||||
|
queryKey: ['all-projects'],
|
||||||
|
queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
|
||||||
|
})
|
||||||
|
const existing = useQuery({
|
||||||
|
queryKey: ['pe-detail', editId],
|
||||||
|
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${editId}`)).data,
|
||||||
|
enabled: !!editId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
type: initialType as number,
|
||||||
|
tenGoiThau: '',
|
||||||
|
projectId: '',
|
||||||
|
diaDiem: '',
|
||||||
|
moTa: '',
|
||||||
|
paymentTerms: '',
|
||||||
|
budgetId: '' as string,
|
||||||
|
})
|
||||||
|
|
||||||
|
const eligibleBudgets = useQuery({
|
||||||
|
queryKey: ['eligible-budgets', form.projectId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<Paged<BudgetListItem>>('/budgets', {
|
||||||
|
params: { pageSize: 100, projectId: form.projectId, phase: BudgetPhase.DaDuyet },
|
||||||
|
})
|
||||||
|
return res.data.items
|
||||||
|
},
|
||||||
|
enabled: !!form.projectId,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (existing.data) {
|
||||||
|
setForm({
|
||||||
|
type: existing.data.type,
|
||||||
|
tenGoiThau: existing.data.tenGoiThau,
|
||||||
|
projectId: existing.data.projectId,
|
||||||
|
diaDiem: existing.data.diaDiem ?? '',
|
||||||
|
moTa: existing.data.moTa ?? '',
|
||||||
|
paymentTerms: existing.data.paymentTerms ?? '',
|
||||||
|
budgetId: existing.data.budgetId ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [existing.data])
|
||||||
|
|
||||||
|
const mut = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (editId) {
|
||||||
|
return api.put(`/purchase-evaluations/${editId}`, {
|
||||||
|
id: editId,
|
||||||
|
tenGoiThau: form.tenGoiThau,
|
||||||
|
diaDiem: form.diaDiem || null,
|
||||||
|
moTa: form.moTa || null,
|
||||||
|
paymentTerms: form.paymentTerms || null,
|
||||||
|
budgetId: form.budgetId || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return api.post<{ id: string }>('/purchase-evaluations', {
|
||||||
|
type: form.type,
|
||||||
|
tenGoiThau: form.tenGoiThau,
|
||||||
|
projectId: form.projectId,
|
||||||
|
diaDiem: form.diaDiem || null,
|
||||||
|
moTa: form.moTa || null,
|
||||||
|
paymentTerms: form.paymentTerms || null,
|
||||||
|
budgetId: form.budgetId || null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: res => {
|
||||||
|
toast.success(editId ? 'Đã lưu.' : 'Đã tạo phiếu.')
|
||||||
|
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||||
|
const id = editId ?? (res as { data: { id: string } }).data.id
|
||||||
|
onSaved(id, form.type)
|
||||||
|
},
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<header>
|
||||||
|
<h2 className="text-base font-semibold tracking-tight text-slate-900">
|
||||||
|
{editId ? 'Sửa header phiếu' : 'Tạo phiếu Duyệt NCC mới'}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-[12px] text-slate-500">
|
||||||
|
{editId
|
||||||
|
? 'Chỉ sửa các field thông tin chung — NCC + báo giá + ý kiến nhập ở Panel chi tiết.'
|
||||||
|
: 'Tạo header trước, sau đó nhập NCC + Báo giá + Hạng mục ở Panel chi tiết.'}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Loại quy trình</Label>
|
||||||
|
<Select
|
||||||
|
value={form.type}
|
||||||
|
disabled={!!editId}
|
||||||
|
onChange={e => setForm({ ...form, type: Number(e.target.value) })}
|
||||||
|
>
|
||||||
|
{Object.values(PurchaseEvaluationType).map(t => (
|
||||||
|
<option key={t} value={t}>{PurchaseEvaluationTypeLabel[t]}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Tên gói thầu *</Label>
|
||||||
|
<Input
|
||||||
|
value={form.tenGoiThau}
|
||||||
|
onChange={e => setForm({ ...form, tenGoiThau: e.target.value })}
|
||||||
|
placeholder="vd Cung cấp bê tông"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Dự án *</Label>
|
||||||
|
<Select
|
||||||
|
value={form.projectId}
|
||||||
|
disabled={!!editId}
|
||||||
|
onChange={e => setForm({ ...form, projectId: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">-- Chọn --</option>
|
||||||
|
{projects.data?.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{p.code} — {p.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Ngân sách (đối chiếu chi phí)</Label>
|
||||||
|
<Select
|
||||||
|
value={form.budgetId}
|
||||||
|
disabled={!form.projectId}
|
||||||
|
onChange={e => setForm({ ...form, budgetId: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">— (không link)</option>
|
||||||
|
{eligibleBudgets.data?.map(b => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">
|
||||||
|
{!form.projectId
|
||||||
|
? 'Chọn dự án trước để xem ngân sách khả dụng.'
|
||||||
|
: eligibleBudgets.data && eligibleBudgets.data.length === 0
|
||||||
|
? 'Dự án này chưa có ngân sách đã duyệt.'
|
||||||
|
: 'Chỉ list ngân sách đã duyệt cùng dự án.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Địa điểm</Label>
|
||||||
|
<Input
|
||||||
|
value={form.diaDiem}
|
||||||
|
onChange={e => setForm({ ...form, diaDiem: e.target.value })}
|
||||||
|
placeholder="Lô K, KCN Lộc An - Bình Sơn..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Mô tả</Label>
|
||||||
|
<Textarea rows={3} value={form.moTa} onChange={e => setForm({ ...form, moTa: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Điều khoản thanh toán (JSON hoặc text)</Label>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
value={form.paymentTerms}
|
||||||
|
onChange={e => setForm({ ...form, paymentTerms: e.target.value })}
|
||||||
|
placeholder='{"tamUng":"10%","thanhToanTam":"100% W.done","quyetToan":"Final Account","baoHanh":"5%"}'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{onCancel && (
|
||||||
|
<Button variant="ghost" onClick={onCancel}>Hủy</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => mut.mutate()}
|
||||||
|
disabled={!form.tenGoiThau || !form.projectId || mut.isPending}
|
||||||
|
>
|
||||||
|
{editId ? 'Lưu' : 'Tạo phiếu'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
184
fe-user/src/components/pe/PeListPanel.tsx
Normal file
184
fe-user/src/components/pe/PeListPanel.tsx
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
// Pure picker panel cho workspace 2-panel "Thao tác" (Pe_*_Create leaf).
|
||||||
|
// KHÔNG có inline Edit/Delete (per Q1 user 2026-05-07): chỉ click để pick, +
|
||||||
|
// optional sticky bottom "+ Thêm mới" button khi showCreateButton=true.
|
||||||
|
//
|
||||||
|
// Reuse-able: caller quản URL state qua props (search/phase/typeFilter), panel
|
||||||
|
// chỉ render + invoke callbacks. Pendingme vẫn truyền được nếu cần dùng cho
|
||||||
|
// inbox view khác (hiện chỉ workspace dùng pendingMe=false).
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ClipboardCheck, Plus, Search } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Input } from '@/components/ui/Input'
|
||||||
|
import { Select } from '@/components/ui/Select'
|
||||||
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
|
import { SlaTimer } from '@/components/SlaTimer'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import type { Paged } from '@/types/master'
|
||||||
|
import {
|
||||||
|
PurchaseEvaluationPhase,
|
||||||
|
PurchaseEvaluationPhaseColor,
|
||||||
|
PurchaseEvaluationPhaseLabel,
|
||||||
|
PurchaseEvaluationTypeLabel,
|
||||||
|
type PeListItem,
|
||||||
|
} from '@/types/purchaseEvaluation'
|
||||||
|
|
||||||
|
export function PeListPanel({
|
||||||
|
typeFilter,
|
||||||
|
pendingMe = false,
|
||||||
|
selectedId,
|
||||||
|
search,
|
||||||
|
phase,
|
||||||
|
onSelect,
|
||||||
|
onSearchChange,
|
||||||
|
onPhaseChange,
|
||||||
|
showCreateButton = false,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
typeFilter: number | null
|
||||||
|
pendingMe?: boolean
|
||||||
|
selectedId: string | null
|
||||||
|
search: string
|
||||||
|
phase: string
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
onSearchChange: (q: string) => void
|
||||||
|
onPhaseChange: (p: string) => void
|
||||||
|
showCreateButton?: boolean
|
||||||
|
onCreate?: () => void
|
||||||
|
}) {
|
||||||
|
const list = useQuery({
|
||||||
|
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase }],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (pendingMe) {
|
||||||
|
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
|
||||||
|
params: { type: typeFilter ?? undefined },
|
||||||
|
})
|
||||||
|
return { items: res.data, total: res.data.length, page: 1, pageSize: res.data.length }
|
||||||
|
}
|
||||||
|
const res = await api.get<Paged<PeListItem>>('/purchase-evaluations', {
|
||||||
|
params: {
|
||||||
|
pageSize: 50,
|
||||||
|
search: search || undefined,
|
||||||
|
type: typeFilter ?? undefined,
|
||||||
|
phase: phase || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = list.data?.items ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
|
||||||
|
{/* Header — count + filter */}
|
||||||
|
<div className="space-y-2 border-b border-slate-200 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Danh sách phiếu
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||||
|
{list.data?.total ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={e => onSearchChange(e.target.value)}
|
||||||
|
placeholder="Tìm mã / tên gói thầu / dự án…"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={phase} onChange={e => onPhaseChange(e.target.value)}>
|
||||||
|
<option value="">Tất cả phase</option>
|
||||||
|
{Object.values(PurchaseEvaluationPhase).map(p => (
|
||||||
|
<option key={p} value={p}>{PurchaseEvaluationPhaseLabel[p]}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List body */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{list.isLoading && (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!list.isLoading && rows.length === 0 && (
|
||||||
|
<div className="p-6">
|
||||||
|
<EmptyState
|
||||||
|
icon={ClipboardCheck}
|
||||||
|
title="Chưa có phiếu"
|
||||||
|
description={
|
||||||
|
showCreateButton
|
||||||
|
? 'Bấm + Thêm mới ở dưới để tạo phiếu đầu tiên.'
|
||||||
|
: 'Chưa có phiếu nào khớp bộ lọc.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{rows.map(p => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect(p.id)}
|
||||||
|
className={cn(
|
||||||
|
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
|
||||||
|
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||||
|
<span className="font-mono">{p.maPhieu ?? '—'}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="truncate">{p.projectName}</span>
|
||||||
|
</div>
|
||||||
|
{p.selectedSupplierName && (
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-emerald-600">
|
||||||
|
✓ {p.selectedSupplierName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
PurchaseEvaluationPhaseColor[p.phase],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{PurchaseEvaluationPhaseLabel[p.phase]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
|
||||||
|
{PurchaseEvaluationTypeLabel[p.type]}
|
||||||
|
</span>
|
||||||
|
<SlaTimer deadline={p.slaDeadline} createdAt={p.createdAt} />
|
||||||
|
</div>
|
||||||
|
{p.contractId && (
|
||||||
|
<div className="mt-1 text-[10px] text-brand-600">✓ Đã tạo HĐ</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sticky bottom — "+ Thêm mới" button (mirror HĐ Thầu phụ pattern) */}
|
||||||
|
{showCreateButton && (
|
||||||
|
<div className="border-t border-slate-200 bg-white p-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => onCreate?.()}
|
||||||
|
className="w-full justify-center gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Thêm mới
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
fe-user/src/pages/pe/PurchaseEvaluationWorkspacePage.tsx
Normal file
140
fe-user/src/pages/pe/PurchaseEvaluationWorkspacePage.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
// Workspace 2-panel cho leaf "Thao tác" Pe_*_Create (Type A=DuyetNcc / B=
|
||||||
|
// DuyetNccPhuongAn). Pattern mirror HĐ Thầu phụ ContractCreatePage:
|
||||||
|
// Panel 1 (320px): list pure picker (read-only, không edit/delete) + sticky
|
||||||
|
// "+ Thêm mới" bottom button (Q1 user 2026-05-07).
|
||||||
|
// Panel 2 (1fr): empty state · mode=new <PeHeaderForm> · else
|
||||||
|
// <PeDetailTabs mode="workspace"> (5 section + Section 5
|
||||||
|
// Ý kiến 4PB DISABLED — Q5: nhập ở leaf "Duyệt").
|
||||||
|
//
|
||||||
|
// URL: /purchase-evaluations/workspace?type={1|2}[&id=...][&mode=new][&q=][&phase=]
|
||||||
|
// Workflow Panel + Approvals + History KHÔNG render ở workspace (Q1 — chỉ
|
||||||
|
// hiện ở leaf Danh sách + Duyệt vẫn 3-panel).
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { ClipboardCheck } from 'lucide-react'
|
||||||
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
|
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
|
||||||
|
import { PeListPanel } from '@/components/pe/PeListPanel'
|
||||||
|
import { PeHeaderForm } from '@/components/pe/PeHeaderForm'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
|
import {
|
||||||
|
PurchaseEvaluationType,
|
||||||
|
PurchaseEvaluationTypeLabel,
|
||||||
|
type PeDetailBundle,
|
||||||
|
} from '@/types/purchaseEvaluation'
|
||||||
|
|
||||||
|
export function PurchaseEvaluationWorkspacePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [sp, setSp] = useSearchParams()
|
||||||
|
const typeFilter = sp.get('type') ? Number(sp.get('type')) : PurchaseEvaluationType.DuyetNcc
|
||||||
|
const search = sp.get('q') ?? ''
|
||||||
|
const phase = sp.get('phase') ?? ''
|
||||||
|
const selectedId = sp.get('id')
|
||||||
|
const mode = sp.get('mode') // 'new' | null
|
||||||
|
|
||||||
|
const detail = useQuery({
|
||||||
|
queryKey: ['pe-detail', selectedId],
|
||||||
|
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${selectedId}`)).data,
|
||||||
|
enabled: !!selectedId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Đã xóa phiếu.')
|
||||||
|
setParams({ id: null })
|
||||||
|
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||||
|
},
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
function setParams(updates: Record<string, string | null>) {
|
||||||
|
const next = new URLSearchParams(sp)
|
||||||
|
for (const [k, v] of Object.entries(updates)) {
|
||||||
|
if (v == null || v === '') next.delete(k)
|
||||||
|
else next.set(k, v)
|
||||||
|
}
|
||||||
|
// Search input gõ liên tục → replace (không spam history); pick/mode → push
|
||||||
|
const replace = Object.keys(updates).length === 1 && updates.q !== undefined
|
||||||
|
setSp(next, { replace })
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerTitle = `${PurchaseEvaluationTypeLabel[typeFilter]} — Thao tác`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||||
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-5 w-5 text-slate-500" />
|
||||||
|
<h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-slate-500">
|
||||||
|
Workspace 2-panel — Workflow + Duyệt ở menu “Duyệt”.
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[320px_1fr]">
|
||||||
|
{/* Panel 1: List pure picker + sticky create */}
|
||||||
|
<PeListPanel
|
||||||
|
typeFilter={typeFilter}
|
||||||
|
selectedId={selectedId}
|
||||||
|
search={search}
|
||||||
|
phase={phase}
|
||||||
|
onSelect={id => setParams({ id, mode: null })}
|
||||||
|
onSearchChange={q => setParams({ q })}
|
||||||
|
onPhaseChange={p => setParams({ phase: p })}
|
||||||
|
showCreateButton
|
||||||
|
onCreate={() => setParams({ mode: 'new', id: null })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panel 2: Empty | Header form | Detail tabs (workspace mode) */}
|
||||||
|
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
|
||||||
|
{/* Empty: chưa pick + chưa create */}
|
||||||
|
{!selectedId && mode !== 'new' && (
|
||||||
|
<EmptyState
|
||||||
|
icon={ClipboardCheck}
|
||||||
|
title="Chọn phiếu hoặc tạo mới"
|
||||||
|
description='Chọn 1 phiếu ở danh sách trái để nhập liệu, hoặc bấm "+ Thêm mới" ở dưới.'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mode "new": header form */}
|
||||||
|
{mode === 'new' && (
|
||||||
|
<PeHeaderForm
|
||||||
|
defaultType={typeFilter}
|
||||||
|
onSaved={(newId, t) => setParams({ id: newId, mode: null, type: String(t) })}
|
||||||
|
onCancel={() => setParams({ mode: null })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mode "edit": detail tabs (workspace = no workflow + Section 5 disabled) */}
|
||||||
|
{selectedId && detail.isLoading && (
|
||||||
|
<div className="text-sm text-slate-500">Đang tải…</div>
|
||||||
|
)}
|
||||||
|
{selectedId && detail.data && (
|
||||||
|
<PeDetailTabs
|
||||||
|
evaluation={detail.data}
|
||||||
|
onBack={() => setParams({ id: null })}
|
||||||
|
onDelete={() => del.mutate(detail.data!.id)}
|
||||||
|
mode="workspace"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile fallback: nếu không lg, redirect về detail page */}
|
||||||
|
{selectedId && (
|
||||||
|
<div className="lg:hidden">
|
||||||
|
{/* Quick UX: tap row khi mobile sẽ navigate fullpage detail */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/purchase-evaluations/${selectedId}`)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user