[CLAUDE] Contract: KHKK 3-panel mirror Duyet NCC (GD2 het lech kien-truc) + 4 mitigation cuong-hanh
Dua man Ke hoach Ky ket HD ve dung lop kien-truc ma GD1 (PE) va GD3 (HD) dang
dung: 1 man 3-panel h-[calc(100vh-4rem)] grid [400px_1fr_360px], thay cho
list 2-panel + detail trang rieng co workflow xep chong. GD2 la mat-xich lech
duy nhat trong 4 giai-doan truoc dot nay.
BE (7/7):
- DetailDto +5 field: WorkflowVersion / EndedByLevelFinalize / FinalizeStepName
/ FinalizeLevelName / Approvals. 2 field Finalize* KHONG phai field append —
ContractSigningPlan chi co co bool, phai DAN XUAT (lam in-memory tu cay
workflow da nap, thay vi port 3 subquery cua PE).
- ContractSigningPlanApprovalDto de moi (truoc do 0 hit toan repo).
- StepDto +DepartmentName (nuoi chip phong panel 3 + header nhom Section 5).
- GET {id}/changelogs — policy KeHoachKyKet.Read.
- POST {id}/approval-attachments + enum +ApprovalAttachment=3 (append-only).
- approvals[] loc bo Decision=Pending de so (N) cung nghia voi (N) cua PE.
- KHONG chep khuon authz cua PurchaseEvaluationsController: file do la mot lo
gotcha #82 (class [Authorize] tran + changelogs khong policy).
FE (x2 app, SHA256 byte-identical 8/8):
- components/khkk/{KhkkListPanel,KhkkDetailContent,KhkkWorkflowPanel}.tsx;
KhkkWorkflowPanel doi tu pages/ sang components/.
- Panel 2: 5 Section danh so, Section 5 "Y kien cap duyet" duoi cung — khoi nay
DOI TU panel 3 sang panel 2 (owner khoanh do trong anh); luoi 2 cot + meta
"x/y cap da duyet · N NV tham gia" thay list phang 1 cot.
- Panel 3: banner ket-thuc-som, chip ten phong, badge Duyet thay CEO, lam mo
buoc sau finalize, khoi File dinh kem khi duyet, Lich su duyet, Lich su
thay doi. Chua chon phieu -> hop dashed y PE (QD owner).
4 mitigation cuong-hanh (owner chot panel 1 = PipelineTreePanel; chon HINH
khong phai chap-nhan VO):
- M-1 fallback !canPe -> danh-sach KHKK phang. Khong co no, user chi co quyen
KHKK (vd nguoi duyet nhom N5) MAT TRANG danh-sach du API van cho doc —
display-layer khoa authz-layer dang mo, mat con lai cua #82.
- M-2 onOpen thanh callback tu host; mac-dinh giu navigate cu => 0 hoi-quy
trang PE/GD3.
- B1 stage2Content di CAP voi stage2Count qua conditional-spread:
khong truyen => stage2 van nhan groups => trang PE GIU NGUYEN 8 ngan N1-N8.
- R-11 search/loc di server-side, KHONG loc-tren-cay; giu dong canh-bao
"Cay tai 200 phieu moi nhat".
Chong hoi-quy da do: build 3/3 PASS · acceptance tinh 20/20 x 2 app ·
mirror SHA256 8/8 · 0 hit path cam (components|pages)/(pe|contracts) ·
KHONG ghi filter/view/group vao URL (chong tai-sinh bug S155 sidebar sang
nham leaf) · giu set-state-in-render :97-101 (dung khuon React, refactor
thanh useEffect se tai-hien va-5).
Con no, khai thang:
- E-7 bang "cot nao chet, chuyen di dau" (8 cot + 6 KpiCard -> card 400px)
THIEU: lane FE chet o luot tra ve, ly-do bo cot mat theo. Phai soi diff
bang mat truoc UAT, neu khong dung ca M-7 mat-chuc-nang-im-lang.
- Test 643/644. 1 FLAKY Create_DifferentEvaluationType_DoesNotAffect_Other
(full-suite 2->1->1, chay rieng 0/0/0; PeWorkflowAdminTests tao
new SqliteDbFixture() rieng x6 test). Baseline tren HEAD sach CHUA do
(git worktree fail duong-dan Dropbox) => CHUA duoc noi "flake co san".
- 5 acceptance RUNTIME cho UAT: approvals co dong Tra lai + ten nguoi (DB dev
0 hang KHKK) · UAT fe-user:8080 an-danh · deep-link fe-admin:8082 (menu
KHKK bi an trong ADMIN_HIDDEN_KEYS) · negative-control go fallback canPe ·
khoi file tu-an khi rong.
- P3-12 4 return-mode = HOAN, khong phai buoc-khac: tien-de "KHKK 3 tram ngan"
do lai la SAI (DbInitializer:554 = 3 Buoc x <=2 Cap, seed tu ghi "sao tu
workflow Duyet NCC prod").
Spec + 6 artifact phan-bien: .claude/workflows/runs/2026-08-04-S171-khkk-ui-mirror-pe/
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -1,49 +1,47 @@
|
||||
// [W2 KHKK — S161 2026-07-29] Danh sách "Kế hoạch ký kết HĐ" (GĐ2).
|
||||
// Khuôn `pages/office/ProposalsListPage.tsx` (PURO layout: ui/PageHeader + hàng
|
||||
// KpiCard làm bộ lọc + bảng + phân trang). File MIRROR SHA256 identical với
|
||||
// fe-admin counterpart.
|
||||
// [S171] HOST 3-PANEL của màn "Kế hoạch ký kết HĐ" (GĐ2) — đưa GĐ2 về CÙNG bố-cục với
|
||||
// Duyệt NCC (GĐ1, `pages/pe/PurchaseEvaluationsListPage.tsx:381,397`) và Hợp đồng
|
||||
// (GĐ3, `pages/contracts/ContractsListPage.tsx:106,125`). Trước S171 trang này là
|
||||
// `space-y-5` cuộn-body 2 panel ⇒ mắt-xích lệch DUY NHẤT trong 4 giai-đoạn.
|
||||
// File MIRROR SHA256 identical với fe-admin counterpart.
|
||||
//
|
||||
// URL vào từ menu (Layout.tsx — staticMap cho nhóm 1, regex `Khkk_G{n}_*` cho nhóm
|
||||
// 2..8; đây là "chỗ thứ 4" của Pattern 16-bis):
|
||||
// Khuôn code lấy từ `ContractsListPage.tsx` (255 dòng, gọn) — CỐ Ý KHÔNG chép
|
||||
// `PurchaseEvaluationsListPage.tsx` (807 dòng: kéo theo `deleted`/`pendingMe`/`awId`/overlay).
|
||||
// Số đo panel lấy của PE (400px) vì hiến-pháp owner là "giống nhất với Duyệt NCC".
|
||||
//
|
||||
// URL vào từ menu (Layout.tsx — staticMap nhóm 1, regex `Khkk_G{n}_*` nhóm 2..8):
|
||||
// Khkk_List / Khkk_G{n}_List → /khkk/list?group={n}
|
||||
// Khkk_Pending / Khkk_G{n}_Pending → /khkk/list?group={n}&filter=ChoDuyet
|
||||
// Khkk_Approved / Khkk_G{n}_Approved → /khkk/list?group={n}&filter=DaDuyet
|
||||
// Khkk_Deleted / Khkk_G{n}_Deleted → /khkk/list?group={n}&view=deleted
|
||||
// 🔴 Chip lọc chỉ đổi STATE, KHÔNG ghi lại URL — giữ nguyên URL của leaf đang đứng
|
||||
// nên menu không sáng nhầm (bug UAT S155 khi URL leaf trùng URL user tự lọc).
|
||||
//
|
||||
// [K4b S166] 🔴 8 nhóm dùng CHUNG một route ⇒ mọi thứ lấy từ URL phải ĐỌC LẠI MỖI
|
||||
// RENDER, không chụp một lần lúc mount: đi từ leaf nhóm 2 sang leaf nhóm 5 là đổi
|
||||
// query chứ không đổi route, React Router không dựng lại component nên `useState(...)`
|
||||
// khởi tạo một lần sẽ giữ nguyên bộ lọc cũ (trước K4b phải F5 mới đúng — vá-5).
|
||||
// Bộ lọc người dùng bấm được giữ ở lớp override và tự nhả ra khi URL đổi (xem dưới).
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
// 🔴 [M-4] KHOÁ URL ĐƯỢC PHÉP GHI = `id` (∈ `TRANSIENT_QUERY_KEYS`, `Layout.tsx:350`).
|
||||
// TUYỆT ĐỐI KHÔNG ghi `filter` / `view` / `group` vào `setSearchParams`: đứng ở leaf
|
||||
// `?group=1` mà ghi thêm `filter=DaDuyet` là URL khớp CHÍNH XÁC leaf "Đã duyệt" của nhóm ⇒
|
||||
// sidebar sáng nhầm mục — đúng bug UAT S155. Bộ lọc người dùng bấm sống ở LỚP OVERRIDE
|
||||
// (state) như bản cũ, không ghi ngược URL.
|
||||
//
|
||||
// 🔴 [K4b S166 · R-5] 8 nhóm dùng CHUNG một route ⇒ mọi thứ lấy từ URL phải ĐỌC LẠI MỖI
|
||||
// RENDER. Khối reset dưới đây là SET-STATE-TRONG-RENDER **CÓ CHỦ ĐÍCH** (khuôn React
|
||||
// "reset state khi input đổi") — ai refactor thành `useEffect` sẽ tái-hiện vá-5 (đi từ
|
||||
// leaf nhóm 2 sang leaf nhóm 5 phải F5 mới đúng). ĐỪNG ĐỘNG.
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
Plus, Search, FileCheck, FileEdit, SendHorizontal,
|
||||
CheckCircle2, Undo2, XCircle, Layers, Inbox, Trash2,
|
||||
} from 'lucide-react'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { KpiCard } from '@/components/ui/KpiCard'
|
||||
import { FileCheck, Plus, X } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
||||
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
|
||||
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
|
||||
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
KHKK_APPROVAL_GROUP_LABELS,
|
||||
KHKK_PHASE_BADGE,
|
||||
KHKK_PHASE_LABELS,
|
||||
KhkkPhase,
|
||||
khkkGroupMenuLabel,
|
||||
type KhkkListItemDto,
|
||||
type KhkkDetailDto,
|
||||
type KhkkPhaseValue,
|
||||
type PagedResult,
|
||||
} from '@/types/khkk'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
// Tên phase trong query `?filter=` → int (URL đọc được, không dính số ma thuật).
|
||||
const FILTER_TO_PHASE: Record<string, KhkkPhaseValue> = {
|
||||
DangSoanThao: KhkkPhase.DangSoanThao,
|
||||
@ -53,308 +51,156 @@ const FILTER_TO_PHASE: Record<string, KhkkPhaseValue> = {
|
||||
TuChoi: KhkkPhase.TuChoi,
|
||||
}
|
||||
|
||||
function formatVnd(n: number | null): string {
|
||||
if (n === null || n === undefined) return '—'
|
||||
return n.toLocaleString('vi-VN') + ' đ'
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function KhkkListPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const { user } = useAuth()
|
||||
|
||||
// ---- [K4b S166] 3 trục DANH TÍNH đọc thẳng từ URL, KHÔNG chụp một lần lúc mount ----
|
||||
// Cả 8 nhóm dùng CHUNG một route `/khkk/list`; bấm leaf này sang leaf kia chỉ đổi
|
||||
// query, React Router KHÔNG remount ⇒ giá trị khởi tạo của `useState` không chạy lại.
|
||||
// Đó là lý do trước K4b bấm leaf→leaf phải F5 mới thấy đúng (vá-5).
|
||||
// ---- 3 trục DANH TÍNH đọc thẳng từ URL (chỉ ĐỌC — không bao giờ ghi lại, xem M-4) ----
|
||||
const urlFilter = searchParams.get('filter')
|
||||
const urlPhase: KhkkPhaseValue | null = urlFilter ? (FILTER_TO_PHASE[urlFilter] ?? null) : null
|
||||
const deletedView = searchParams.get('view') === 'deleted'
|
||||
// `?group=` — chỉ nhận 1..8; rác/thiếu ⇒ null = XEM TẤT CẢ NHÓM (không tự đoán nhóm 1,
|
||||
// vì "vắng tham số" và "nhóm 1" là hai ý khác nhau; leaf nhóm 1 nay mang group=1 tường minh).
|
||||
const groupParam = searchParams.get('group')
|
||||
const group = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
|
||||
const urlGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
|
||||
const selectedId = searchParams.get('id')
|
||||
|
||||
// ---- Bộ lọc do NGƯỜI DÙNG bấm (6 chip KpiCard) đè lên bộ lọc của URL ----
|
||||
// 🔴 3 trạng thái, KHÔNG gộp được: `undefined` = chưa bấm chip ⇒ nghe theo URL;
|
||||
// `null` = user chủ động chọn "Tất cả"; số = user chọn một trạng thái.
|
||||
// Gộp `undefined` với `null` thì chọn "Tất cả" sẽ bị URL kéo ngược về filter cũ.
|
||||
// ---- Lớp override do NGƯỜI DÙNG bấm, đè lên bộ lọc của URL ----
|
||||
// 🔴 3 trạng thái, KHÔNG gộp được: `undefined` = chưa chạm ⇒ nghe theo URL; `null` = user
|
||||
// chủ động chọn "Tất cả"; số = user chọn một giá trị. Gộp `undefined` với `null` thì chọn
|
||||
// "Tất cả" sẽ bị URL kéo ngược về bộ lọc cũ.
|
||||
const [chipPhase, setChipPhase] = useState<KhkkPhaseValue | null | undefined>(undefined)
|
||||
const [groupOverride, setGroupOverride] = useState<number | null | undefined>(undefined)
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
// Đổi leaf ⇒ trả bộ lọc chip về cho URL và về trang 1 (đang đứng trang 5 của nhóm 2
|
||||
// mà nhảy sang nhóm 5 thì trang 5 gần như chắc chắn rỗng). Điều chỉnh state NGAY
|
||||
// TRONG RENDER theo khuôn React "reset state khi input đổi" — dùng `useEffect` sẽ
|
||||
// vẽ một lượt bằng dữ liệu cũ rồi mới sửa (nháy). `search` CỐ Ý giữ lại: đang tìm
|
||||
// một mã rồi đổi nhóm để dò tiếp là thao tác có thật.
|
||||
const navKey = `${group ?? ''}|${urlFilter ?? ''}|${deletedView ? 'del' : ''}`
|
||||
// Đổi leaf ⇒ trả bộ lọc về cho URL và về trang 1. `search` CỐ Ý giữ lại: đang tìm một mã
|
||||
// rồi đổi nhóm để dò tiếp là thao tác có thật.
|
||||
const navKey = `${urlGroup ?? ''}|${urlFilter ?? ''}|${deletedView ? 'del' : ''}`
|
||||
const [lastNavKey, setLastNavKey] = useState(navKey)
|
||||
if (navKey !== lastNavKey) {
|
||||
setLastNavKey(navKey)
|
||||
setChipPhase(undefined)
|
||||
setGroupOverride(undefined)
|
||||
setPage(1)
|
||||
}
|
||||
const phase: KhkkPhaseValue | null = chipPhase !== undefined ? chipPhase : urlPhase
|
||||
const group: number | null = groupOverride !== undefined ? groupOverride : urlGroup
|
||||
|
||||
const list = useQuery({
|
||||
// 🔴 [vá-3] `group` PHẢI nằm trong khoá cache. Thiếu nó thì 8 leaf "Danh sách" của
|
||||
// 8 nhóm dùng CHUNG một ô cache ⇒ mở nhóm 5 lại thấy dữ liệu nhóm 2 trong khoảnh
|
||||
// khắc trước khi refetch, và tệ hơn là không refetch nếu còn fresh (sai CÂM, không
|
||||
// có lỗi nào nổ). Trục `?filter=` đã nằm sẵn trong khoá dưới dạng ĐÃ GIẢI MÃ là
|
||||
// `phase` (`FILTER_TO_PHASE` đơn ánh, và `phase` cũng chính là thứ gửi lên server)
|
||||
// ⇒ khoá này phủ đủ cả hai trục vá-3 nhắc; thêm `filter` thô nữa chỉ tách đôi ô
|
||||
// cache cho hai request y hệt nhau.
|
||||
queryKey: ['khkk-list', { group, phase, search, page, deletedView }],
|
||||
queryFn: async () => {
|
||||
// Phiếu xóa mềm bị HasQueryFilter che ở list thường — chỉ endpoint riêng
|
||||
// `/deleted` thấy (mirror PE `/purchase-evaluations/deleted`, S155).
|
||||
const url = deletedView ? '/contract-signing-plans/deleted' : '/contract-signing-plans'
|
||||
return (await api.get<PagedResult<KhkkListItemDto>>(url, {
|
||||
params: {
|
||||
phase: deletedView ? undefined : (phase ?? undefined),
|
||||
// [K4b S166 — FLAG-1 ĐÃ NỐI CẢ 2 NHÁNH] `/deleted` nay nhận `group` mirror
|
||||
// List (lead vá BE cùng lượt: Controller + `ListDeletedContractSigningPlansQuery`
|
||||
// + handler lọc SAU vế IDOR). Gửi thẳng cho cả hai — lọc là việc server,
|
||||
// total/số-trang vì thế nói thật ở cả màn sống lẫn màn đã-xóa.
|
||||
group: group ?? undefined,
|
||||
search: search.trim() || undefined,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
},
|
||||
})).data
|
||||
},
|
||||
const detail = useQuery({
|
||||
queryKey: ['khkk-detail', selectedId],
|
||||
queryFn: async () => (await api.get<KhkkDetailDto>(`/contract-signing-plans/${selectedId}`)).data,
|
||||
enabled: !!selectedId,
|
||||
})
|
||||
|
||||
const items = list.data?.items ?? []
|
||||
const total = list.data?.total ?? 0
|
||||
const totalPages = list.data?.totalPages ?? 1
|
||||
function setId(id: string | null) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (id == null) next.delete('id')
|
||||
else next.set('id', id)
|
||||
setSearchParams(next)
|
||||
}
|
||||
|
||||
// Presentation-only: đếm trên trang đang tải (không fetch thêm). Card đang active
|
||||
// hiện `total` thật từ server; card khác chỉ là gợi ý nhìn nhanh.
|
||||
const countByPhase = useMemo(() => {
|
||||
const acc: Record<number, number> = { 1: 0, 2: 0, 3: 0, 98: 0, 99: 0 }
|
||||
for (const p of items) acc[p.phase] = (acc[p.phase] ?? 0) + 1
|
||||
return acc
|
||||
}, [items])
|
||||
function selectPlan(id: string) {
|
||||
// `<lg` không có 3 panel ⇒ mở trang chi tiết đầy đủ (route `/khkk/:id` GIỮ NGUYÊN).
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
|
||||
setId(id)
|
||||
} else {
|
||||
navigate(group !== null ? `/khkk/${id}?group=${group}` : `/khkk/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const phaseCards: Array<{
|
||||
value: KhkkPhaseValue | null
|
||||
label: string
|
||||
icon: ReactNode
|
||||
accent: 'brand' | 'teal' | 'violet' | 'amberx' | 'greenx'
|
||||
count: number
|
||||
}> = [
|
||||
{ value: null, label: 'Tất cả', icon: <Layers className="h-4 w-4" />, accent: 'brand', count: phase === null ? total : items.length },
|
||||
{ value: KhkkPhase.DangSoanThao, label: KHKK_PHASE_LABELS[1], icon: <FileEdit className="h-4 w-4" />, accent: 'violet', count: phase === 1 ? total : countByPhase[1] },
|
||||
{ value: KhkkPhase.ChoDuyet, label: KHKK_PHASE_LABELS[2], icon: <SendHorizontal className="h-4 w-4" />, accent: 'amberx', count: phase === 2 ? total : countByPhase[2] },
|
||||
{ value: KhkkPhase.DaDuyet, label: KHKK_PHASE_LABELS[3], icon: <CheckCircle2 className="h-4 w-4" />, accent: 'greenx', count: phase === 3 ? total : countByPhase[3] },
|
||||
{ value: KhkkPhase.TraLai, label: KHKK_PHASE_LABELS[98], icon: <Undo2 className="h-4 w-4" />, accent: 'violet', count: phase === 98 ? total : countByPhase[98] },
|
||||
{ value: KhkkPhase.TuChoi, label: KHKK_PHASE_LABELS[99], icon: <XCircle className="h-4 w-4" />, accent: 'amberx', count: phase === 99 ? total : countByPhase[99] },
|
||||
]
|
||||
const plan = detail.data
|
||||
// [P2-7] `readOnly` do HOST quyết — KHÔNG cần dựng workspace riêng để có bản chỉ-xem.
|
||||
const readOnly =
|
||||
!plan
|
||||
|| !(plan.phase === KhkkPhase.DangSoanThao || plan.phase === KhkkPhase.TraLai)
|
||||
|| (!!user?.id && plan.drafterUserId !== user.id)
|
||||
|
||||
const headerTitle = deletedView
|
||||
? (group !== null ? `Kế hoạch ký kết HĐ — N${group} · đã xóa` : 'Kế hoạch ký kết HĐ — đã xóa')
|
||||
: group !== null ? `Kế hoạch ký kết HĐ — N${group}` : 'Kế hoạch ký kết HĐ'
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
eyebrow="Giai đoạn 2"
|
||||
title={
|
||||
deletedView
|
||||
? (group !== null ? `Kế hoạch ký kết HĐ — N${group} · đã xóa` : 'Kế hoạch ký kết HĐ — đã xóa')
|
||||
: group !== null
|
||||
? `Kế hoạch ký kết HĐ — N${group}`
|
||||
: 'Kế hoạch ký kết HĐ'
|
||||
}
|
||||
subtitle={
|
||||
// [K4b S166] Màn "Đã xóa" nay ĐƯỢC in tên nhóm — `/deleted` đã lọc server-side
|
||||
// theo `group` (FLAG-1 nối cùng lượt), tên nhóm hết là lời nói dối.
|
||||
deletedView
|
||||
? (group !== null ? `${khkkGroupMenuLabel(group)} — phiếu đã xóa mềm, chỉ xem` : 'Phiếu đã xóa mềm — chỉ xem danh sách')
|
||||
: group !== null
|
||||
? khkkGroupMenuLabel(group)
|
||||
: 'Chốt giá per-NCC trúng thầu + căn cứ hồ sơ trước khi ký hợp đồng'
|
||||
}
|
||||
icon={deletedView ? <Trash2 className="h-5 w-5" /> : <FileCheck className="h-5 w-5" />}
|
||||
accent="brand"
|
||||
actions={
|
||||
deletedView ? undefined : (
|
||||
// Mang nhóm đang xem sang màn lập phiếu — không bắt người dùng chọn lại
|
||||
// đúng cái nhóm họ vừa đứng (bắt tay với vá-6 bên `KhkkCreatePage`).
|
||||
<Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Lập kế hoạch
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* [S162] Cây toàn trình bám trái + nội dung cũ giữ nguyên bên phải (owner
|
||||
chốt AskUser 30-07: "áp cả các trang GĐ khác"). `minmax(0,1fr)` để bảng
|
||||
8 cột không thổi vỡ lưới ở 1366; `<lg` cây xếp trên và tự thu gọn. */}
|
||||
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)] xl:grid-cols-[21rem_minmax(0,1fr)]">
|
||||
<PipelineTreePanel currentStage={2} />
|
||||
|
||||
<div className="min-w-0 space-y-5">
|
||||
{!deletedView && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||
{phaseCards.map((c) => (
|
||||
<KpiCard
|
||||
key={c.value ?? 'all'}
|
||||
label={c.label}
|
||||
value={c.count}
|
||||
icon={c.icon}
|
||||
accent={c.accent}
|
||||
active={phase === c.value}
|
||||
onClick={() => {
|
||||
// Ghi vào lớp override, KHÔNG ghi vào URL — giữ URL của leaf đang đứng
|
||||
// để sidebar không sáng nhầm mục khác (bài S155, xem ghi chú đầu file).
|
||||
setChipPhase(c.value)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<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 min-w-0 items-center gap-2">
|
||||
<FileCheck className="h-5 w-5 text-slate-500" />
|
||||
<h1 className="truncate text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
|
||||
<span className="ml-2 truncate rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||
Giai đoạn 2{group !== null ? ` · ${khkkGroupMenuLabel(group)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-accent flex items-center gap-3 px-4 py-3" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
||||
<Search className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder="Tìm mã kế hoạch, mã phiếu hoặc tên gói thầu..."
|
||||
className="max-w-md border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
||||
{/* [S162] Cột trái ăn ~19rem ⇒ bảng 8 cột cuộn ngang trong thẻ thay vì
|
||||
tràn ra ngoài lưới trên laptop 1366. */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-200 bg-slate-50/70">
|
||||
<tr>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Mã kế hoạch</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Gói thầu</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Dự án</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Trạng thái</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-right">Tổng đề xuất</th>
|
||||
{/* [K2 S164 — vá-8] Σ `ApprovedAmount` (`ListItemDto:64`). Không có cột này
|
||||
thì màn danh sách nói "Tổng đề xuất" còn màn chi tiết nói "Giá duyệt"
|
||||
trên CÙNG một phiếu đã duyệt ⇒ người dùng đọc ra hai con số. */}
|
||||
<th className="label-eyebrow px-4 py-2.5 text-right">Tổng duyệt</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Người soạn</th>
|
||||
<th className="label-eyebrow px-4 py-2.5 text-left">Ngày tạo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.isLoading && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
|
||||
Đang tải...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!list.isLoading && items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-10 text-center text-slate-500">
|
||||
<span
|
||||
className="icon-chip mx-auto mb-2 flex"
|
||||
style={{ ['--chip-bg' as string]: '#f1f5f9', ['--chip-fg' as string]: '#94a3b8' }}
|
||||
aria-hidden
|
||||
>
|
||||
<Inbox className="h-4 w-4" />
|
||||
</span>
|
||||
{deletedView ? 'Không có phiếu đã xóa.' : 'Chưa có kế hoạch ký kết nào.'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.map((k) => (
|
||||
<tr
|
||||
key={k.id}
|
||||
onClick={() => !deletedView && navigate(`/khkk/${k.id}`)}
|
||||
className={cn(
|
||||
'border-b border-slate-100 transition last:border-0',
|
||||
deletedView ? 'opacity-70' : 'cursor-pointer hover:bg-brand-50/50',
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span
|
||||
className="icon-chip h-7! w-7!"
|
||||
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<FileCheck className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="font-mono text-xs text-brand-800">{k.maKeHoach ?? '—'}</span>
|
||||
{/* [K2 S164] Badge NHÓM DUYỆT — đặt cạnh mã thay vì đẻ cột thứ 9:
|
||||
bảng này đã cuộn ngang ở 1366 khi có cây bên trái (ghi chú S162).
|
||||
`!= null` guard cho payload cũ còn trong cache React Query (trước
|
||||
K2 DTO chưa có field) — thiếu guard thì in ra "Nundefined". */}
|
||||
{k.approvalGroup != null && (
|
||||
<span
|
||||
className="inline-flex items-center rounded border border-violet-300 bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-800"
|
||||
title={KHKK_APPROVAL_GROUP_LABELS[k.approvalGroup] ?? `Nhóm ${k.approvalGroup}`}
|
||||
>
|
||||
N{k.approvalGroup}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-xs truncate px-4 py-2.5 font-medium text-brand-800">
|
||||
{k.peTenGoiThau ?? '—'}
|
||||
{k.peMaPhieu && <span className="ml-2 font-mono text-[11px] text-slate-400">{k.peMaPhieu}</span>}
|
||||
</td>
|
||||
<td className="max-w-[14rem] truncate px-4 py-2.5 text-xs text-slate-600">{k.projectName ?? '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
|
||||
KHKK_PHASE_BADGE[k.phase as KhkkPhaseValue],
|
||||
)}
|
||||
>
|
||||
{KHKK_PHASE_LABELS[k.phase as KhkkPhaseValue]}
|
||||
</span>
|
||||
{k.currentApprovalLevelOrder && (
|
||||
<span className="ml-2 text-xs text-slate-500">Cấp {k.currentApprovalLevelOrder}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-brand-800">{formatVnd(k.totalProposedAmount)}</td>
|
||||
{/* Phiếu chưa duyệt BE trả 0 (không phải null) ⇒ ép về "—" cho khỏi đọc
|
||||
nhầm "đã duyệt 0 đ"; số chỉ hiện khi thật sự có tiền đã chốt. */}
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-emerald-700">
|
||||
{k.totalApprovedAmount ? formatVnd(k.totalApprovedAmount) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-slate-600">{k.drafterFullName ?? '—'}</td>
|
||||
<td className="px-4 py-2.5 text-xs text-slate-600">{formatDate(k.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
|
||||
<div className="text-slate-500">
|
||||
{total} kế hoạch — Trang {page} / {totalPages}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
Trước
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
Sau
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!deletedView && (
|
||||
<Button onClick={() => navigate(group !== null ? `/khkk/create?group=${group}` : '/khkk/create')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Lập kế hoạch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
|
||||
{/* Panel 1 — cây toàn trình (owner QĐ-1) + 4 mitigation, xem KhkkListPanel.tsx */}
|
||||
<KhkkListPanel
|
||||
selectedId={selectedId}
|
||||
onSelect={selectPlan}
|
||||
search={search}
|
||||
onSearchChange={(v) => { setSearch(v); setPage(1) }}
|
||||
phase={phase}
|
||||
onPhaseChange={(v) => { setChipPhase(v); setPage(1) }}
|
||||
group={group}
|
||||
onGroupChange={(v) => { setGroupOverride(v); setPage(1) }}
|
||||
deletedView={deletedView}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
selectedFinalize={
|
||||
plan
|
||||
? { endedByLevelFinalize: plan.endedByLevelFinalize, finalizeStepName: plan.finalizeStepName }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Panel 2 — nội dung phiếu. [F-1] EmptyState là trạng-thái ĐẦU TIÊN người dùng thấy;
|
||||
thiếu nó thì host 3-panel mở ra là một mảng trắng ở giữa. */}
|
||||
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
|
||||
{!selectedId && (
|
||||
<EmptyState
|
||||
icon={FileCheck}
|
||||
title={deletedView ? 'Phiếu đã xóa — chỉ xem danh sách' : 'Chọn phiếu ở danh sách'}
|
||||
description={deletedView
|
||||
? 'Mục này chỉ liệt kê phiếu đã xóa để đối chiếu. Không mở chi tiết, không thao tác.'
|
||||
: 'Nội dung kế hoạch + dòng giá NCC + căn cứ hồ sơ + ý kiến cấp duyệt sẽ hiển thị ở đây.'}
|
||||
/>
|
||||
)}
|
||||
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
||||
{selectedId && !detail.isLoading && !plan && (
|
||||
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
|
||||
)}
|
||||
{selectedId && plan && (
|
||||
<KhkkDetailContent plan={plan} readOnly={readOnly} onChanged={() => detail.refetch()} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Panel 3 — quy trình duyệt + lịch sử.
|
||||
🔴 OWNER CHỐT (QĐ-2): chưa chọn phiếu ⇒ CHỈ hộp dashed y hệt Duyệt NCC
|
||||
(`PurchaseEvaluationsListPage.tsx:669-676`). KHÔNG nhét `PipelineTreePanel` vào
|
||||
đây — cây đã sống ở panel 1 theo QĐ-1, nhét lần hai là trùng. Khác GĐ3 ở điểm
|
||||
này là CÓ CHỦ ĐÍCH. */}
|
||||
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
|
||||
{!selectedId && (
|
||||
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
|
||||
<X className="mx-auto mb-2 h-5 w-5" />
|
||||
{deletedView
|
||||
? 'Phiếu đã xóa không còn quy trình duyệt.'
|
||||
: 'Quy trình duyệt sẽ hiện khi chọn phiếu.'}
|
||||
</div>
|
||||
)}
|
||||
{selectedId && plan && (
|
||||
<KhkkWorkflowPanel
|
||||
plan={plan}
|
||||
onChanged={() => detail.refetch()}
|
||||
onDeleted={() => setId(null)}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,491 +0,0 @@
|
||||
// [W3 KHKK — S161 2026-07-29] Panel duyệt 3 trạm (PMH → CCM → CEO) cho Kế hoạch ký kết HĐ.
|
||||
// File MIRROR SHA256 identical với fe-admin counterpart.
|
||||
//
|
||||
// Khuôn: `components/pe/PeWorkflowPanel.tsx` — GIỮ sơ đồ Bước→Cấp ✓/●/○, banner "đến lượt bạn"
|
||||
// (`blockedByV2Level` :109-114), 3 nút màu (emerald/amber/red), Dialog + ô ý kiến.
|
||||
// ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH (spec W3 §②-3): KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt,
|
||||
// KHÔNG 4 return-mode (W3 = TraLai toàn phần). Chốt giá per-Line là việc BE làm ở finalize
|
||||
// choke-point, KHÔNG phải việc của panel này.
|
||||
//
|
||||
// 🔴 [K3 S164/S165 — SUPERSEDE] Câu cũ ở đây khai "KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize /
|
||||
// CeoApprovalThreshold — anh phán «PHÁ VỠ» S155; W1 đã rào ở Designer type-10)". KHAI ĐÓ ĐÃ HẾT HIỆU LỰC:
|
||||
// owner ĐẢO phán quyết S155 (@S164 — "a Chương quyết cái gì cần CEO") ⇒ BE **đã port nhánh finalize cho
|
||||
// KHKK ở K3 cùng wave**, và Designer type-10 đã GỠ rào (`ApprovalWorkflowsV2Page.tsx` — hằng
|
||||
// `earlyFinalizeLocked` xoá hẳn, 2 ô tick sống lại). Đừng đọc file này thành "BE không hỗ trợ".
|
||||
// 🔴 [F-1 S166 — SUPERSEDE tiếp] Câu cũ "panel không có control riêng cho đường kết-thúc-sớm" HẾT hiệu lực:
|
||||
// gate-K3 chấm nó là F-1 MAJOR (BE `?? true` mà 0 UI gửi false ⇒ trạm CEO chỉ đi được bằng admin bỏ cờ
|
||||
// Designer). Nay panel CÓ ô-tích "Cấp này KẾT THÚC" theo khuôn PE-LIVE S97 = OPT-IN (G-1 reviewer:
|
||||
// PE `useState(false)` :61 — owner đã ĐẢO opt-out S96 → opt-in S97; nhãn cũ "khuôn S96" trích trúng
|
||||
// chú-thích chết). Default KHÔNG tick ⇒ gửi false ⇒ trình tiếp; TÍCH ⇒ true ⇒ BE kết thúc tại cấp.
|
||||
// RATIFIED @S167 2026-08-01 (anh: "Đồng ý. Cứ theo khuôn PE") — default OPT-IN khuôn PE-live-S97 là CHỐT.
|
||||
//
|
||||
// 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do BE precompute.
|
||||
// KHKK BE trả CÂY THÔ (`ContractSigningPlanFeatures.cs:112-117` + `:105-110`) — KHÔNG có `status`.
|
||||
// Panel này TỰ SUY trạng thái từ cặp con-trỏ:
|
||||
// • `currentWorkflowStepIndex` = INDEX 0-based vào mảng `workflowSteps` (đã sort Order) — KHÔNG phải Order
|
||||
// • `currentApprovalLevelOrder` = GIÁ TRỊ `level.order`
|
||||
// (BE tự khai ngữ nghĩa này ở `ContractSigningPlanFeatures.cs:224-226`; luật so khớp OR-of-N canonical
|
||||
// `:259-260` = `steps[idx].Levels.Any(l => l.Order == cur && l.ApproverUserId == uid)` — mirror Y NGUYÊN
|
||||
// bên dưới, dùng `.some()` vì N Cấp CÙNG Order = OR-of-N, chỉ 1 người ký là tiến.)
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, MessageSquare, Send, Undo2, Workflow, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import {
|
||||
KHKK_PHASE_LABELS, KhkkPhase, KhkkTransitionAction,
|
||||
type KhkkDetailDto, type KhkkPhaseValue, type KhkkTransitionActionValue,
|
||||
type KhkkTransitionInput, type KhkkTransitionResult, type KhkkWorkflowLevelDto,
|
||||
} from '@/types/khkk'
|
||||
|
||||
type NodeStatus = 'Done' | 'Current' | 'Pending'
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString('vi-VN')
|
||||
}
|
||||
|
||||
// Pattern 14 (Tailwind JIT): chuỗi class ĐẦY ĐỦ, KHÔNG nội suy mảnh (`bg-${x}-50` bị purge).
|
||||
const STATUS_DOT: Record<NodeStatus, string> = {
|
||||
Done: 'bg-emerald-500 text-white',
|
||||
Current: 'bg-brand-600 text-white',
|
||||
Pending: 'bg-slate-200 text-slate-500',
|
||||
}
|
||||
|
||||
const STATUS_BOX: Record<NodeStatus, string> = {
|
||||
Done: 'border-emerald-200 bg-emerald-50/40',
|
||||
Current: 'border-brand-300 bg-brand-50/50',
|
||||
Pending: 'border-slate-200 bg-white',
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<NodeStatus, string> = {
|
||||
Done: '✓',
|
||||
Current: '●',
|
||||
Pending: '○',
|
||||
}
|
||||
|
||||
export function KhkkWorkflowPanel({
|
||||
plan,
|
||||
onChanged,
|
||||
}: {
|
||||
plan: KhkkDetailDto
|
||||
/** gọi sau khi transition thành công — caller refetch detail/list theo id URL của nó. */
|
||||
onChanged?: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const { user } = useAuth()
|
||||
const [action, setAction] = useState<KhkkTransitionActionValue | null>(null)
|
||||
const [comment, setComment] = useState('')
|
||||
// [F-1 S166 · G-1 reviewer] Ô-tích level-finalize theo khuôn PE-LIVE S97 = OPT-IN
|
||||
// (`PeWorkflowPanel.tsx:61` useState(false) — owner đảo opt-out S96 → opt-in S97 "cái này option
|
||||
// thôi"; đừng trích comment S96 chết ở `:769-771` file đó). Default UNCHECKED = duyệt xong TRÌNH
|
||||
// TIẾP cấp sau (fail-mode an toàn: không đọc thì phiếu vẫn lên CEO); TÍCH mới KẾT THÚC tại cấp.
|
||||
// Hành vi mặc định trạm CCM đổi so trước-F-1 (BE `?? true` auto-finalize) — RATIFIED @S167 (khuôn PE);
|
||||
// lật lại (nếu đổi ý) = đổi 1 chữ false→true + 2 nhãn dưới.
|
||||
const [applyLevelFinalize, setApplyLevelFinalize] = useState(false)
|
||||
|
||||
const phase = plan.phase as KhkkPhaseValue
|
||||
const steps = plan.workflowSteps ?? []
|
||||
const opinions = plan.levelOpinions ?? []
|
||||
|
||||
const isWaiting = phase === KhkkPhase.ChoDuyet
|
||||
const isApproved = phase === KhkkPhase.DaDuyet
|
||||
const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
|
||||
|
||||
const curStepIdx = plan.currentWorkflowStepIndex
|
||||
const curLevelOrder = plan.currentApprovalLevelOrder
|
||||
|
||||
// Suy trạng thái Bước: chỉ có nghĩa khi phiếu ĐANG chờ duyệt. DaDuyet ⇒ tất cả Done;
|
||||
// Nháp/TraLai/TuChoi ⇒ tất cả Pending (quy trình chưa chạy / đã dừng).
|
||||
const stepStatus = (idx: number): NodeStatus => {
|
||||
if (isApproved) return 'Done'
|
||||
if (!isWaiting || curStepIdx == null) return 'Pending'
|
||||
if (idx < curStepIdx) return 'Done'
|
||||
if (idx === curStepIdx) return 'Current'
|
||||
return 'Pending'
|
||||
}
|
||||
|
||||
const levelStatus = (idx: number, order: number): NodeStatus => {
|
||||
const s = stepStatus(idx)
|
||||
if (s !== 'Current' || curLevelOrder == null) return s
|
||||
if (order < curLevelOrder) return 'Done'
|
||||
if (order === curLevelOrder) return 'Current'
|
||||
return 'Pending'
|
||||
}
|
||||
|
||||
// 🔴 OR-of-N: mọi Cấp CÙNG `order` trong Bước hiện tại đều là người được duyệt lượt này —
|
||||
// 1 người ký là phiếu tiến (mirror BE `:259-260` `.Any(...)`). KHÔNG `.find()` một người.
|
||||
const currentLevels: KhkkWorkflowLevelDto[] =
|
||||
isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length && curLevelOrder != null
|
||||
? steps[curStepIdx].levels.filter(l => l.order === curLevelOrder)
|
||||
: []
|
||||
const currentStep = isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length
|
||||
? steps[curStepIdx]
|
||||
: null
|
||||
|
||||
const isAdmin = user?.roles?.includes('Admin') ?? false
|
||||
const actorIsCurrentApprover = !!user?.id && currentLevels.some(l => l.approverUserId === user.id)
|
||||
// Admin bypass — mirror `PeWorkflowPanel.tsx:110-114` (`actorInV2Level` có `isAdmin ||`).
|
||||
const actorInLevel = isAdmin || actorIsCurrentApprover
|
||||
const blockedByLevel = isWaiting && !actorInLevel
|
||||
|
||||
// [F-1 S166] Ô-tích opt-out hiện theo cờ của ĐÚNG level BE sẽ chấm — mirror `ResolveActingLevel`
|
||||
// (`ContractSigningPlanWorkflowService.cs:455-469`): own-level TRƯỚC (kể cả Admin có slot riêng),
|
||||
// Admin không có own ⇒ level ĐẦU nhóm cùng-order. Soi cờ của level KHÁC là hiện ô-tích nói dối.
|
||||
const actingLevel = currentLevels.find(l => l.approverUserId === user?.id)
|
||||
?? (isAdmin ? currentLevels[0] : undefined)
|
||||
const approverFinalizeEligible = actingLevel?.allowApproverFinalize === true
|
||||
|
||||
// Nút "Gửi duyệt": guard BE là `CreatedBy == actor ∨ DeptManager(cùng phòng) ∨ Admin`
|
||||
// (spec W3 §②-5). FE KHÔNG suy được vế DeptManager — `UserInfo` chỉ có {id,email,fullName,roles}
|
||||
// (`types/auth.ts:1-6`), không có phòng ban. CỐ Ý **không ẩn** nút theo vai: ẩn sẽ giấu nút với
|
||||
// đúng người có quyền, im lặng, không lỗi (bug-class gotcha #44 — đã đốt 1 lần ở PE S155).
|
||||
// Rào THẬT nằm ở BE (403 → toast). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
|
||||
const canSubmit = isDraftLike && !!plan.approvalWorkflowId
|
||||
|
||||
const transition = useMutation({
|
||||
mutationFn: async (a: KhkkTransitionActionValue) => {
|
||||
// 🔴 BODY theo hợp-đồng LEAD chốt W3 (`action` + `comment`) + [F-1 S166] field thứ 3
|
||||
// `applyLevelFinalize` CHỈ đính kèm khi approve: cấp có cờ gửi giá trị ô-tích, cấp thường
|
||||
// gửi true = no-op (mirror PE `PeWorkflowPanel.tsx:259`; BE `bool? = null` ⇒ absent = true).
|
||||
const body: KhkkTransitionInput = {
|
||||
action: a,
|
||||
comment: comment.trim() || null,
|
||||
...(a === KhkkTransitionAction.Approve
|
||||
? { applyLevelFinalize: approverFinalizeEligible ? applyLevelFinalize : true }
|
||||
: {}),
|
||||
}
|
||||
const res = await api.post<KhkkTransitionResult>(
|
||||
`/contract-signing-plans/${plan.id}/transitions`,
|
||||
body,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
const label = KHKK_PHASE_LABELS[res.phase as KhkkPhaseValue] ?? `Phase ${res.phase}`
|
||||
toast.success(`Đã cập nhật — trạng thái: ${label}.`)
|
||||
setAction(null)
|
||||
setComment('')
|
||||
qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] })
|
||||
qc.invalidateQueries({ queryKey: ['khkk-list'] })
|
||||
onChanged?.()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const dialogTitle =
|
||||
action === KhkkTransitionAction.Submit ? '➤ Gửi duyệt kế hoạch ký kết'
|
||||
: action === KhkkTransitionAction.Approve ? '✓ Duyệt kế hoạch ký kết'
|
||||
: action === KhkkTransitionAction.Return ? '← Trả lại người soạn sửa'
|
||||
: '✗ Từ chối kế hoạch ký kết'
|
||||
// Trả lại / Từ chối bắt buộc nêu lý do (ghi vào ý kiến cấp duyệt + changelog BE).
|
||||
const commentRequired =
|
||||
action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject
|
||||
const confirmDisabled = transition.isPending || (commentRequired && !comment.trim())
|
||||
|
||||
return (
|
||||
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}>
|
||||
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
|
||||
<span
|
||||
className="icon-chip"
|
||||
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-emerald-50)', ['--chip-fg' as string]: 'var(--color-emerald-600)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<Workflow className="h-4 w-4" />
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-emerald-700">Quy trình duyệt</h3>
|
||||
{plan.workflowCode && (
|
||||
<span className="font-mono text-[11px] text-slate-500">
|
||||
{plan.workflowCode}
|
||||
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="space-y-4 p-5">
|
||||
{/* Sơ đồ Bước → Cấp. ✓ Done (emerald) / ● Current (brand) / ○ Pending (slate) */}
|
||||
{steps.length > 0 ? (
|
||||
<ol className="space-y-2">
|
||||
{steps.map((step, idx) => {
|
||||
const st = stepStatus(idx)
|
||||
return (
|
||||
<li key={step.id} className={cn('rounded-md border p-2', STATUS_BOX[st])}>
|
||||
<div className="flex items-center gap-2 text-xs font-medium">
|
||||
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-bold', STATUS_DOT[st])}>
|
||||
{STATUS_ICON[st]}
|
||||
</span>
|
||||
<span className="text-slate-800">Bước {step.order} — {step.name}</span>
|
||||
</div>
|
||||
{step.levels.length > 0 && (
|
||||
<ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
|
||||
{step.levels.map(lv => {
|
||||
const ls = levelStatus(idx, lv.order)
|
||||
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
|
||||
return (
|
||||
<li key={lv.id} className="text-[11px]">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className={cn('mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold', STATUS_DOT[ls])}>
|
||||
{STATUS_ICON[ls]}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-slate-700">
|
||||
{lv.name || `Cấp ${lv.order}`}
|
||||
{ls === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
|
||||
{ls === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
|
||||
</div>
|
||||
<div className="text-slate-500">
|
||||
{lv.approverFullName ?? '(chưa cấu hình)'}
|
||||
{signed && (
|
||||
<span className="ml-1.5 text-[10px] text-emerald-600">
|
||||
✓ ký {formatDateTime(signed.signedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
) : (
|
||||
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
|
||||
Quy trình duyệt chưa cấu hình Bước/Cấp — liên hệ Admin để dựng quy trình loại
|
||||
“Kế hoạch ký kết HĐ” trước khi trình duyệt.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banner "đến lượt bạn" — khuôn PeWorkflowPanel:489-508. Chỉ có nghĩa khi ĐANG chờ duyệt. */}
|
||||
{isWaiting && currentStep && (
|
||||
<div className={cn(
|
||||
'rounded border px-3 py-2 text-[11px]',
|
||||
actorInLevel
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
||||
: 'border-amber-300 bg-amber-50 text-amber-800',
|
||||
)}>
|
||||
<div className="font-medium">
|
||||
Đang chờ Bước {currentStep.order} ({currentStep.name}) — Cấp {curLevelOrder}
|
||||
</div>
|
||||
<div className="mt-0.5">
|
||||
Người duyệt: {currentLevels.map(l => l.approverFullName ?? '(chưa rõ)').join(' / ') || '(chưa có)'}
|
||||
</div>
|
||||
{actorInLevel
|
||||
? <div className="mt-0.5 font-medium">✓ Đến lượt bạn duyệt</div>
|
||||
: <div className="mt-0.5">⚠ Không phải lượt bạn — chỉ người trên mới thao tác cấp này</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === KhkkPhase.TraLai && (
|
||||
<div className="rounded border border-orange-300 bg-orange-50 px-3 py-2 text-[11px] text-orange-800">
|
||||
⚠ Kế hoạch bị TRẢ LẠI — sửa nội dung rồi bấm “Gửi duyệt”, quy trình chạy lại từ Bước 1 · Cấp 1.
|
||||
</div>
|
||||
)}
|
||||
{phase === KhkkPhase.TuChoi && (
|
||||
<div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
||||
✗ Kế hoạch đã bị TỪ CHỐI — không thao tác được nữa. Lập kế hoạch mới nếu cần làm lại.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hành động */}
|
||||
{(canSubmit || isWaiting) && (
|
||||
<div>
|
||||
<Label className="text-xs">Hành động:</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{canSubmit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setComment(''); setAction(KhkkTransitionAction.Submit) }}
|
||||
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-bold text-brand-700 transition hover:bg-brand-50"
|
||||
>
|
||||
<Send className="h-3 w-3" /> Gửi duyệt
|
||||
</button>
|
||||
)}
|
||||
{isDraftLike && !plan.approvalWorkflowId && (
|
||||
<span className="rounded border border-dashed border-slate-300 px-2 py-1 text-[11px] text-slate-500">
|
||||
Chưa pin quy trình duyệt — không gửi duyệt được.
|
||||
</span>
|
||||
)}
|
||||
{isWaiting && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={blockedByLevel}
|
||||
onClick={() => {
|
||||
if (!blockedByLevel) {
|
||||
setComment('')
|
||||
// [F-1 S166 · G-1] reset về default KHÔNG-tick (opt-IN, khuôn PE S97) mỗi
|
||||
// lần mở dialog — lựa chọn tích là per-lượt-duyệt, không dính lần trước.
|
||||
setApplyLevelFinalize(false)
|
||||
setAction(KhkkTransitionAction.Approve)
|
||||
}
|
||||
}}
|
||||
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
||||
blockedByLevel
|
||||
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
|
||||
: 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
|
||||
)}
|
||||
>
|
||||
<Check className="h-3 w-3" /> Duyệt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={blockedByLevel}
|
||||
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Return) } }}
|
||||
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
||||
blockedByLevel
|
||||
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
|
||||
: 'border-amber-300 text-amber-700 hover:bg-amber-50',
|
||||
)}
|
||||
>
|
||||
<Undo2 className="h-3 w-3" /> Trả lại
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={blockedByLevel}
|
||||
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Reject) } }}
|
||||
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Từ chối kế hoạch (khoá phiếu)'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
||||
blockedByLevel
|
||||
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
|
||||
: 'border-red-300 text-red-700 hover:bg-red-50',
|
||||
)}
|
||||
>
|
||||
<X className="h-3 w-3" /> Từ chối
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ý kiến cấp duyệt (UPSERT 1 row/Cấp — BE ghi khi người duyệt bấm Duyệt kèm ý kiến). */}
|
||||
{opinions.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
|
||||
<MessageSquare className="h-3.5 w-3.5 text-violet-500" />
|
||||
Ý kiến cấp duyệt
|
||||
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
|
||||
{opinions.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-1.5 space-y-1.5">
|
||||
{opinions.map(o => (
|
||||
<li key={o.id} className="rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]">
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
|
||||
<span className="font-medium">{o.signedByFullName}</span>
|
||||
<span className="text-slate-400">·</span>
|
||||
<span>
|
||||
{o.stepName ? `Bước ${o.stepOrder} — ${o.stepName}` : 'Cấp duyệt'}
|
||||
{o.levelOrder != null && ` · Cấp ${o.levelOrder}`}
|
||||
</span>
|
||||
<span className="text-slate-400">·</span>
|
||||
<span className="text-slate-500">{formatDateTime(o.signedAt)}</span>
|
||||
{/* Người ký ≠ người được phân công ⇒ Admin duyệt thay (mirror banner PE S17). */}
|
||||
{o.approverUserId && o.approverUserId !== o.signedByUserId && (
|
||||
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-800">
|
||||
duyệt thay
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
|
||||
{o.comment?.trim() || '(duyệt — không ý kiến)'}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action !== null && (
|
||||
<Dialog
|
||||
open
|
||||
onClose={() => setAction(null)}
|
||||
title={dialogTitle}
|
||||
footer={<>
|
||||
<Button variant="outline" onClick={() => setAction(null)}>Huỷ</Button>
|
||||
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
|
||||
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
{action === KhkkTransitionAction.Submit && (
|
||||
<div className="mb-3 rounded border border-brand-200 bg-brand-50 px-3 py-2 text-[11px] text-brand-800">
|
||||
Kế hoạch chuyển sang “Chờ duyệt” và trình lên Bước 1 · Cấp 1 của quy trình
|
||||
{plan.workflowName ? ` "${plan.workflowName}"` : ''}. Sau khi trình, nội dung nháp không sửa được nữa.
|
||||
</div>
|
||||
)}
|
||||
{action === KhkkTransitionAction.Return && (
|
||||
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||||
Kế hoạch về trạng thái “Trả lại”. Người soạn sửa xong gửi lại thì quy trình chạy
|
||||
từ Bước 1 · Cấp 1.
|
||||
</div>
|
||||
)}
|
||||
{action === KhkkTransitionAction.Reject && (
|
||||
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
||||
⚠ Kế hoạch bị khoá hoàn toàn (không sửa / không duyệt tiếp). Người soạn phải lập kế hoạch mới.
|
||||
</div>
|
||||
)}
|
||||
{/* [F-1 S166 · G-1/G-5 reviewer] Cấp cấu hình KẾT THÚC (AllowApproverFinalize) = ô-tích
|
||||
OPT-IN, khuôn PE-LIVE S97 (`PeWorkflowPanel.tsx:61` useState(false) + JSX `:772-791`;
|
||||
comment "opt-out S96" ngay trên khối JSX đó là chú-thích CHẾT — G-1 bắt đúng bẫy này):
|
||||
default KHÔNG tick = duyệt xong TRÌNH TIẾP cấp sau; TÍCH = gửi applyLevelFinalize=true
|
||||
⇒ BE kết thúc tại cấp. Không viết "(CEO)" — cấp-sau là gì tuỳ cấu hình quy trình (G-5).
|
||||
KHÁC PE: không nhắc "chọn giá chốt" — KHKK chốt giá per-dòng do BE tự làm ở finalize
|
||||
choke-point (`ApplyApprovedValuesOnFinalize`), panel không có price-picker. */}
|
||||
{action === KhkkTransitionAction.Approve && approverFinalizeEligible && (
|
||||
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
|
||||
<label className="flex cursor-pointer items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={applyLevelFinalize}
|
||||
onChange={e => setApplyLevelFinalize(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Cấp này KẾT THÚC quy trình</span>
|
||||
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
|
||||
{applyLevelFinalize
|
||||
? 'Đã tích — duyệt xong là kế hoạch "Đã duyệt", KẾT THÚC tại cấp này, KHÔNG trình tiếp cấp sau.'
|
||||
: 'Mặc định: duyệt xong kế hoạch TRÌNH TIẾP lên cấp sau của quy trình. TÍCH vào nếu muốn KẾT THÚC ngay tại cấp này.'}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<Label>
|
||||
Ý kiến{commentRequired ? <span className="text-red-500"> *</span> : ' (tuỳ chọn)'}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ý kiến của bạn (ghi vào phiếu)...'}
|
||||
/>
|
||||
{action === KhkkTransitionAction.Approve && (
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
Ý kiến sẽ được ghi vào mục “Ý kiến cấp duyệt” của kế hoạch (1 ô/Cấp — duyệt lại
|
||||
thì ghi đè ô cũ).
|
||||
</p>
|
||||
)}
|
||||
</Dialog>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user