All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m11s
Anh review truc tiep tren prod, khoanh do tung cho. Land tron cum:
=== 1. GOM NUT THAO TAC (anh: "Bo het di" + "phia duoi them cac nut thao tac"
+ "cho menu dang duyet -> Duyet/Tra lai vay thoi") ===
· THANH NUT DAY moi o `KhkkDetailContent` (sticky bottom-0, khuon PE workspace):
[Xoa phieu] · dong "Cac thay doi da tu dong luu" · [Luu & Gui duyet ->]
· Panel 3 GO "Gui duyet" + "Xoa phieu" => nay CHI con **Duyet / Tra lai**.
· GO nut "+ Lap ke hoach" (header) · nut "Sua" (muc 1) · dai banner "Dang xem ke hoach".
🔴 THU TU CO CHU DICH: KHONG go "Gui duyet" o panel 3 TRUOC khi thanh day co mat — go truoc
la nguoi soan mat duong trinh duyet, phieu ket o Nhap. Vi vay ca cum di CHUNG 1 commit.
🔴 Thanh day chi om nhanh **Submit** — nhanh Approve/Return (o-tich level-finalize + upload
file sau transition) O LAI panel 3. Nhan ban nhanh do sang day la de su-that thu hai cho
duong DUYET.
🔸 KHONG lam nut "Luu" roi: moi o o man nay luu NGAY khi roi o. Them nut khong lam gi la
hua hao — noi thang bang dong chu ben canh thay vi ve nut gia.
🔸 Giu lai dung 1 dong nho "Ve form tao" — bo han thi mo 1 phieu la ket, khong co duong lui.
· `onDeleted` noi vao ca 3 host (List / Create / Detail) de xoa xong khong dung o phieu ma.
=== 2. HANG MUC CUA DU AN (anh: "hang muc cua Du an -. Duyet NCC ay") ===
🔴 Lead bay NHAM o luot truoc: bay `line.tenHangMuc` = danh-muc **SP-002** (86 dong, dung
phan loai HOP DONG). Anh muon **hang muc CONG VIEC cua du an** tu phieu Duyet NCC
(`PE.WorkItemId`). Hai thu trung ten tieng Viet nhung KHAC TRUC — tron la chi sai goi thau.
· BE: `ContractSigningPlanDetailDto` +`PeWorkItemName`; handler join `WorkItems` (loose-Guid
Mig 49, KHONG FK vat ly => join tay null-safe).
· FE: o "Hang muc (du an)" doc `plan.peWorkItemName`; go het khoi liet-ke SP-002 khoi muc 1.
=== 3. MUC 2 RUT VE "HANG MUC + GIA -> TONG" ===
Anh: "Cho nay doi lai la hang muc la dc roi, chon hang muc tien -> thanh tong con lai bo het"
+ "cho chon nhieu dong nhap gia thanh gia tong, ko can so sanh j voi so duyet PRO hay CCM".
· Tieu de: "2. Gia de xuat theo NCC trung thau" -> "2. Hang muc".
· Cot: 6 -> 3 (HANG MUC | GIA | thao-tac). GO **Nha cung cap · Gia tham chieu · Gia duyet**.
· Ghi chu xuong DONG PHU duoi o hang muc (van nhap duoc, khong chiem cot).
· +hang TONG = Sigma gia cac dong.
🔸 Du-lieu 2 cot go di VAN trong DTO; `peReferenceAmount` con dung lam DAU phan biet
dong-goc / dong-them-tay o nut xoa.
=== 4. LOC THEO NHOM (anh: "Loc theo quy trinh luon") ===
Tang duoi man Thao tac hien phieu N1/N7 trong khi dang dung o leaf N2 => doc nham "nhom nay
dang co viec". Nay loc theo `approvalGroup` cua leaf menu. `group == null` => khong loc
(giu duong cu cho host khong truyen).
=== 5. GO DAI ACCENT + 2 O LINK HO SO/GHI CHU ===
· `card-accent` boc `KhkkDetailContent` GO HAN (dai xanh doc man Thao tac). GO CLASS chu
KHONG de `border-0` — class nay ngoai `@layer` nen thang utility (gotcha #66).
· Khoi "Link ho so + Ghi chu" GO TRON ca 2 nhanh ternary. Luot truoc lead moi go o luoi tren
nen 2 o van hien o khoi duoi — anh chup lai dung cho do.
=== CON NO ===
🔴 Cay man Thao tac: anh muon **Nam > Du an > Hang muc > 4 giai doan** (giong cay o man Danh
sach). Hien moi co Du an > Hang muc (gap/mo duoc). Chua land.
🔴 CHUA soi mat prod 375/1366/1440.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
410 lines
23 KiB
TypeScript
410 lines
23 KiB
TypeScript
// [S174 owner] PANEL 1 (danh sách) của màn "Kế hoạch ký kết HĐ — Thao tác" — 1 cột, 2 TẦNG:
|
||
// · tầng TRÊN = phiếu Duyệt NCC ĐÃ DUYỆT (có NCC trúng thầu) mà CHƯA lập kế hoạch ký kết,
|
||
// gom theo DỰ ÁN — "việc còn phải làm"; bấm = nạp thẳng vào form tạo;
|
||
// · tầng DƯỚI = kế hoạch ký kết ĐÃ TẠO — "việc đã làm rồi"; bấm = XEM tại chỗ (read-only).
|
||
// File MIRROR SHA256 identical với fe-user counterpart.
|
||
//
|
||
// 🔴 LỊCH SỬ ĐẶT SAI CHỖ — ghi để không tái phạm: bản đầu S174 gắn panel này thành cột 3 của
|
||
// màn *Duyệt NCC — Thao tác* (`PurchaseEvaluationWorkspacePage`). Anh bác: **"chỗ duyệt NCC
|
||
// đã golive và đã tốt rồi"** — ý anh từ đầu là làm màn Thao tác của KHKK **GIỐNG GIAO DIỆN**
|
||
// Thao tác của Duyệt NCC, chứ không phải chèn thêm thứ vào màn Duyệt NCC. Đã revert
|
||
// `PurchaseEvaluationWorkspacePage` về đúng byte của `1b3bcaa`. Bài: "làm giống màn X" ≠
|
||
// "sửa màn X".
|
||
//
|
||
// 🔴 0 ENDPOINT MỚI — dùng lại đúng 2 cửa đã sống:
|
||
// · `GET /contract-signing-plans/approved-pe-awaiting-plan` (chính cửa `KhkkCreatePage`
|
||
// đang gọi) → `KhkkPeCandidateDto[]`; "chưa có kế hoạch" = `existingGroups` RỖNG.
|
||
// 🔸 Từ K2 S164 cửa này KHÔNG còn loại PE đã có kế hoạch (nó trả cả PE đã dùng vài
|
||
// nhóm, kèm `existingGroups`) ⇒ lọc rỗng là việc của FE, đừng tưởng BE lọc hộ.
|
||
// · `GET /contract-signing-plans` → `PagedResult<KhkkListItemDto>`.
|
||
//
|
||
// 🔴 GATE QUYỀN (gotcha #85 — mặt đã cắn thật ở S162): màn đang đứng là *Duyệt NCC*, nhưng
|
||
// CẢ HAI cửa trên gác bằng policy `KeHoachKyKet.Read` (`ContractSigningPlansController.cs:36`
|
||
// + `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key — KHÔNG kế thừa cha, KHÔNG OR key
|
||
// con). Người chỉ có quyền PE mà không có KHKK sẽ ăn 403 rải khắp cột. ⇒ gate bằng ĐÚNG key
|
||
// của endpoint, và khi thiếu quyền thì NÓI RA thay vì để cột trắng trông như "chưa có gì".
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import { ChevronRight, FileCheck, FileSignature, FolderTree, Lock, Pencil } from 'lucide-react'
|
||
import { useState } from 'react'
|
||
import { usePermission } from '@/hooks/usePermission'
|
||
import { api } from '@/lib/api'
|
||
import { cn } from '@/lib/cn'
|
||
import {
|
||
KHKK_PHASE_BADGE,
|
||
KHKK_PHASE_LABELS,
|
||
KhkkPhase,
|
||
type KhkkListItemDto,
|
||
type KhkkPeCandidateDto,
|
||
type PagedResult,
|
||
} from '@/types/khkk'
|
||
|
||
const KHKK_TIER_PAGE_SIZE = 30
|
||
|
||
function formatDate(iso: string): string {
|
||
return new Date(iso).toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||
}
|
||
|
||
/** Vỏ 1 tầng — `min-h-0 flex-1` ở CẢ HAI tầng là thứ tạo ra tỉ lệ 50/50 thật; thiếu
|
||
* `min-h-0` thì con cuộn được sẽ đẩy tầng phình theo nội dung, hỏng tỉ lệ. */
|
||
function Tier({
|
||
title, count, icon, accent, children,
|
||
}: {
|
||
title: string
|
||
count: number | null
|
||
icon: React.ReactNode
|
||
accent: string
|
||
children: React.ReactNode
|
||
}) {
|
||
return (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
<div className="flex shrink-0 items-center gap-2 border-b border-slate-200 bg-slate-50/80 px-3 py-2">
|
||
<span className={cn('inline-flex h-5 w-5 items-center justify-center rounded', accent)} aria-hidden>
|
||
{icon}
|
||
</span>
|
||
<span className="min-w-0 flex-1 truncate text-[11px] font-bold uppercase tracking-wide text-slate-600">
|
||
{title}
|
||
</span>
|
||
{count !== null && (
|
||
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||
{count}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function KhkkWorkListPanel({
|
||
onPickPe,
|
||
onPickPlan,
|
||
selectedPeId,
|
||
selectedPlanId,
|
||
onEditPlan,
|
||
group,
|
||
}: {
|
||
/** Bấm PE ở tầng trên → host nạp phiếu đó vào form tạo (Ở LẠI màn, không điều hướng). */
|
||
onPickPe: (peId: string) => void
|
||
/** Bấm KHKK ở tầng dưới → host mở phiếu đó ở panel 2, CHỈ XEM. Anh chốt: "click vào phiếu
|
||
* nào thì cho hiện phiếu đó luôn, chỉ view là đc, ko re-direct về phiếu gốc". */
|
||
onPickPlan: (planId: string) => void
|
||
selectedPeId?: string | null
|
||
selectedPlanId?: string | null
|
||
/** [S175 · O7] Bấm bút chì trên card KHKK → host mở phiếu ở chế-độ SỬA. Không truyền ⇒
|
||
* không vẽ bút chì (cột giữ đúng hình cũ). */
|
||
onEditPlan?: (planId: string) => void
|
||
/** [S175 - anh: "Loc theo quy trinh luon"] Nhom duyet cua leaf menu dang dung.
|
||
* Tang duoi CHI hien ke hoach thuoc dung nhom nay - dung o leaf N2 ma thay phieu
|
||
* N1/N7 la doc nham "nhom nay dang co viec". `null` = khong loc. */
|
||
group?: number | null
|
||
}) {
|
||
// [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Tập KHOÁ đang GẬP.
|
||
// Dùng tập-gập (không phải tập-mở) để mặc-định là MỞ HẾT: dự án/hạng mục mới xuất hiện sau
|
||
// này tự mở, không bị ẩn im lặng vì chưa ai bấm mở bao giờ.
|
||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||
const { can } = usePermission()
|
||
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị).
|
||
const canKhkk = can('KeHoachKyKet', 'Read')
|
||
|
||
const candidates = useQuery({
|
||
// Key dùng CHUNG với `KhkkCreatePage` là CỐ Ý: tạo kế hoạch xong quay lại đây thì
|
||
// cột tự tươi, không phải nhớ invalidate thêm một key thứ hai.
|
||
queryKey: ['khkk-pe-candidates'],
|
||
queryFn: async () =>
|
||
(await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data,
|
||
enabled: canKhkk,
|
||
})
|
||
|
||
const plans = useQuery({
|
||
queryKey: ['khkk-list', { side: true, page: 1, pageSize: KHKK_TIER_PAGE_SIZE }],
|
||
queryFn: async () =>
|
||
(await api.get<PagedResult<KhkkListItemDto>>('/contract-signing-plans', {
|
||
params: { page: 1, pageSize: KHKK_TIER_PAGE_SIZE },
|
||
})).data,
|
||
enabled: canKhkk,
|
||
})
|
||
|
||
// "Chưa được tạo phiếu KHKK" = chưa dùng nhóm duyệt NÀO. `?? []` phòng payload cũ còn
|
||
// trong cache React Query (DTO trước K2 không có field này) — thiếu guard thì `.length` nổ.
|
||
const awaiting = (candidates.data ?? []).filter((p) => (p.existingGroups ?? []).length === 0)
|
||
|
||
// [S175 · O8] CÂY 2 CẤP **Dự án › Hạng mục** — anh chốt "cho cây thư mục dự án hạng mục
|
||
// ra luôn nhé". Trước đây chỉ gom 1 cấp theo dự án, nên một dự án nhiều gói thầu đổ ra
|
||
// một danh sách phẳng dài, không thấy được ranh giới hạng mục.
|
||
// 🔴 0 ENDPOINT MỚI: `workItemName` ĐÃ có sẵn trong `KhkkPeCandidateDto` (`khkk.ts:423`)
|
||
// ⇒ cấp 2 là gom lại trên dữ-liệu đang có, không phải gọi thêm cửa nào.
|
||
// `Map` giữ thứ tự chèn ở CẢ HAI cấp nên dự án/hạng mục có phiếu mới nhất nổi lên trước
|
||
// (BE đã trả sẵn theo thứ tự).
|
||
const byProject = new Map<string, Map<string, KhkkPeCandidateDto[]>>()
|
||
for (const pe of awaiting) {
|
||
const pKey = pe.projectName ?? '(chưa gán dự án)'
|
||
const wKey = pe.workItemName ?? '(chưa gán hạng mục)'
|
||
let byWorkItem = byProject.get(pKey)
|
||
if (!byWorkItem) { byWorkItem = new Map(); byProject.set(pKey, byWorkItem) }
|
||
const bucket = byWorkItem.get(wKey)
|
||
if (bucket) bucket.push(pe)
|
||
else byWorkItem.set(wKey, [pe])
|
||
}
|
||
/** Đếm phiếu của cả một dự án (cộng mọi hạng mục con) — số ở header cấp 1. */
|
||
const countProject = (byWorkItem: Map<string, KhkkPeCandidateDto[]>) =>
|
||
[...byWorkItem.values()].reduce((s, arr) => s + arr.length, 0)
|
||
|
||
// [S174 owner] "chỗ thao tác chỉ hiển trả lại và nháp, từ chối thôi nhé" ⇒ DANH-SÁCH-TRẮNG
|
||
// 3 trạng thái, KHÔNG phải loại-trừ. 🔴 Đổi từ loại-trừ sang danh-sách-trắng là CÓ CHỦ ĐÍCH:
|
||
// loại-trừ (`!== DaDuyet`) sẽ tự động NHẬN mọi phase mới thêm về sau; danh-sách-trắng thì
|
||
// phase mới phải được khai TƯỜNG MINH mới hiện. Ở màn Thao tác — nơi chỉ nên có việc CÒN
|
||
// SỬA ĐƯỢC — im lặng nhận thêm là hỏng, nên chọn vế chặt.
|
||
// 🔸 `ChoDuyet` CŨNG bị loại (khác lượt trước chỉ bỏ `DaDuyet`): phiếu đang trình duyệt thì
|
||
// người soạn không thao tác gì được nữa.
|
||
// 🔴 KHAI GIỚI-HẠN (lọc ở FE): endpoint chỉ nhận `phase` ĐƠN, không nhận nhiều ⇒ (1) KHÔNG
|
||
// dùng `plans.data.total` làm số hiển thị (nó đếm MỌI phase) — đếm từ mảng đã lọc;
|
||
// (2) cửa-sổ 30 phiếu mới nhất có thể toàn phiếu đã duyệt ⇒ tầng dưới rỗng dù còn nháp
|
||
// cũ hơn. Chấp nhận ở UAT; muốn chuẩn thì BE nhận `phases[]` rồi lọc SERVER-SIDE.
|
||
const OPEN_PHASES: number[] = [KhkkPhase.DangSoanThao, KhkkPhase.TraLai, KhkkPhase.TuChoi]
|
||
const planItems = (plans.data?.items ?? [])
|
||
.filter((k) => OPEN_PHASES.includes(k.phase))
|
||
// [S175] +loc NHOM. `group == null` => khong loc (giu duong cu cho host khong truyen).
|
||
.filter((k) => group == null || k.approvalGroup === group)
|
||
|
||
return (
|
||
<aside className="flex min-h-0 flex-col overflow-hidden border-r border-slate-200 bg-white">
|
||
{!canKhkk ? (
|
||
// Cột trắng đọc thành "chưa có gì" — phải nói ĐÚNG lý do là thiếu quyền.
|
||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
|
||
<Lock className="h-5 w-5 text-slate-300" />
|
||
<p className="text-[11px] text-slate-500">
|
||
Bạn chưa có quyền xem Kế hoạch ký kết HĐ nên cột này không dựng được. Việc duyệt NCC
|
||
ở hai panel bên trái không bị ảnh hưởng.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<Tier
|
||
title="Đã duyệt · chưa lập KHKK"
|
||
count={awaiting.length}
|
||
accent="bg-amber-50 text-amber-600"
|
||
icon={<FileCheck className="h-3 w-3" />}
|
||
>
|
||
{candidates.isLoading && <div className="p-3 text-[11px] text-slate-500">Đang tải…</div>}
|
||
{candidates.isError && (
|
||
<div className="space-y-1.5 p-3">
|
||
<p className="text-[11px] text-slate-500">Không tải được danh sách.</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => candidates.refetch()}
|
||
className="rounded-md border border-slate-300 px-2 py-1 text-[10px] font-semibold text-slate-600 hover:border-brand-300 hover:bg-brand-50"
|
||
>
|
||
Thử lại
|
||
</button>
|
||
</div>
|
||
)}
|
||
{!candidates.isLoading && !candidates.isError && awaiting.length === 0 && (
|
||
<p className="p-3 text-[11px] italic text-slate-500">
|
||
Mọi phiếu đã duyệt đều đã có kế hoạch ký kết.
|
||
</p>
|
||
)}
|
||
{/* [S175 · O8] CẤP 1 = Dự án (sticky, `z-20`) · CẤP 2 = Hạng mục (sticky dưới nó,
|
||
`z-10` + thụt lề + vạch trái). 🔴 Hai `z` khác nhau là BẮT BUỘC: cùng `z` thì
|
||
header hạng mục trượt ĐÈ lên header dự án khi cuộn, mất mất cấp cha. */}
|
||
{[...byProject.entries()].map(([projectName, byWorkItem]) => {
|
||
const pCollapsed = collapsed.has(projectName)
|
||
return (
|
||
<div key={projectName}>
|
||
{/* [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Header CẤP 1 nay là
|
||
NÚT gập/mở. Mặc-định MỞ: gập sẵn thì mở panel ra thấy toàn thư-mục đóng,
|
||
người dùng tưởng chưa có việc nào. */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setCollapsed(prev => {
|
||
const next = new Set(prev)
|
||
if (next.has(projectName)) next.delete(projectName); else next.add(projectName)
|
||
return next
|
||
})}
|
||
aria-expanded={!pCollapsed}
|
||
className="sticky top-0 z-20 flex w-full items-center gap-1.5 border-b border-slate-200 bg-slate-50/95 px-3 py-1 text-left backdrop-blur transition hover:bg-slate-100"
|
||
>
|
||
<ChevronRight className={cn('h-3 w-3 shrink-0 text-slate-400 transition-transform', !pCollapsed && 'rotate-90')} aria-hidden />
|
||
<FolderTree className="h-3 w-3 shrink-0 text-slate-400" aria-hidden />
|
||
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-600">
|
||
{projectName}
|
||
</span>
|
||
<span className="shrink-0 text-[10px] tabular-nums text-slate-500">{countProject(byWorkItem)}</span>
|
||
</button>
|
||
{!pCollapsed && [...byWorkItem.entries()].map(([workItemName, items]) => {
|
||
const wKey = `${projectName} |