[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:
pqhuy1987
2026-08-04 19:48:38 +07:00
parent f151320780
commit 998ea559f3
21 changed files with 4581 additions and 3179 deletions

View File

@ -0,0 +1,388 @@
// [S171 KHKK 3-panel] PANEL 1 của màn "Kế hoạch ký kết HĐ" (GĐ2) — mirror bố-cục
// Duyệt NCC (GĐ1) / Hợp đồng (GĐ3): khối filter trên cùng + thân danh sách cuộn riêng.
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// 🔴 KHỐI Ý-KIẾN / SƠ-ĐỒ DUYỆT là bản thứ 3 của cùng một hình (PE `PeDetailTabs.tsx:705-774`
// · HĐ `components/contracts/WorkflowHistoryPanel.tsx`). Gộp được khi và chỉ khi BE 3 module
// thống-nhất shape `status` precompute. Trước đó, gộp = đẻ `if (module === …)`.
//
// ══ OWNER CHỐT 2026-08-04 (QĐ-1): panel 1 = CÂY TOÀN TRÌNH `PipelineTreePanel currentStage={2}`.
// Chọn HÌNH ≠ chấp-nhận VỠ ⇒ 4 mitigation dưới đây là BẮT BUỘC, không phải tuỳ chọn:
// M-1 `canPe` (`usePipelineStages.ts:83`) khoá cây bằng 3 key của Duyệt NCC. Người CHỈ có
// quyền KHKK (vd người duyệt nhóm N5) sẽ thấy cây trống trong khi API
// `GET /contract-signing-plans` (policy `KeHoachKyKet.Read`) VẪN cho họ đọc — đúng mặt
// còn lại của gotcha #82. ⇒ `!canPe` FALLBACK sang DANH-SÁCH PHẲNG cùng ô này.
// Cây là lớp LÀM GIÀU, KHÔNG phải điều-kiện để thấy phiếu.
// M-2 Leaf/card KHKK trên cây KHÔNG `navigate('/khkk/:id')` nữa mà gọi `onSelect` của host
// (`usePipelineStages.ts` `onOpenKhkk`) — không thì bấm 1 cái là văng khỏi màn 3-panel.
// B1 Truyền `stage2Content` PHẢI kèm `stage2Count` (`PipelineStageFolders.tsx:95-97`).
// Trade-off khai thẳng: trong cây của trang này, folder GĐ2 hiện CARD PHIẾU thay cho
// 8 ngăn N1-N8; lối vào nhóm giữ ở `Select` nhóm ngay bên trên.
// R-11 Cây chỉ tải 200 phiếu PE mới nhất (`PipelineTreePanel.tsx:22`) và KHÔNG có "trang 2".
// ⇒ ô tìm + `Select` trạng-thái đi SERVER-SIDE thẳng `GET /contract-signing-plans`
// (bật chế-độ danh-sách phẳng), luôn có đường tới phiếu cũ; và dòng cảnh-báo
// "Cây tải 200 phiếu mới nhất…" của cây KHÔNG bị nuốt.
import { useQuery } from '@tanstack/react-query'
import { FileCheck, Inbox, Search } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
import { usePipelineStages } from '@/hooks/usePipelineStages'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
KHKK_APPROVAL_GROUPS,
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
KhkkPhase,
khkkGroupMenuLabel,
type KhkkListItemDto,
type KhkkPhaseValue,
type PagedResult,
} from '@/types/khkk'
const PAGE_SIZE = 20
function formatVnd(n: number | null | undefined): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('vi-VN') + ' đ'
}
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
}
/**
* [F-2] Chip "⚑ Kết thúc · {Bước}" — khuôn `components/pe/PeFinalizeChip.tsx` (S89/S97),
* dựng lại ở đây thay vì import vì nguồn cờ khác module (KHKK đọc
* `endedByLevelFinalize` + `finalizeStepName`, PE đọc `endsBeforeCeo`).
*/
export function KhkkFinalizeChip({
endedByLevelFinalize,
finalizeStepName,
phase,
}: {
endedByLevelFinalize: boolean
finalizeStepName: string | null
phase?: number
}) {
if (!endedByLevelFinalize) {
if (phase === KhkkPhase.DaDuyet) {
return (
<span
className="inline-flex shrink-0 items-center rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-800"
title="Kế hoạch đi HẾT quy trình (không kết thúc sớm ở cấp dưới)"
>
Duyệt hết quy trình
</span>
)
}
return null
}
return (
<span
className="inline-flex shrink-0 items-center rounded bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-800"
title={`Quy trình kết thúc tại ${finalizeStepName ?? 'cấp này'} — không trình tiếp cấp sau`}
>
{finalizeStepName ? `⚑ Kết thúc · ${finalizeStepName}` : '⚑ Kết thúc sớm'}
</span>
)
}
/**
* Card 3 hàng — thay cho bảng 8 cột + 6 KpiCard của bản cũ (bảng "cột nào chết, chuyển
* đi đâu" nằm ở `sub-implementer-frontend-s171.md` §4).
* [M-8] Σ tiền in MỘT số, suy từ `phase === DaDuyet` (đúng như BE suy `budgetFrozen`) —
* KHÔNG thêm field vào `KhkkListItemDto`, vì `budgetFrozen` chỉ có ở DetailDto.
*/
export function KhkkPlanCard({
plan,
selected,
onSelect,
finalize,
}: {
plan: KhkkListItemDto
selected: boolean
onSelect: (id: string) => void
/** Chỉ có khi phiếu ĐANG được chọn (detail đã tải) — list payload không mang cờ này. */
finalize?: { endedByLevelFinalize: boolean; finalizeStepName: string | null }
}) {
const frozen = plan.phase === KhkkPhase.DaDuyet
const amount = frozen ? (plan.totalApprovedAmount ?? plan.totalProposedAmount) : plan.totalProposedAmount
return (
<button
type="button"
onClick={() => onSelect(plan.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
selected && '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="flex flex-wrap items-center gap-1">
{finalize && (
<KhkkFinalizeChip
endedByLevelFinalize={finalize.endedByLevelFinalize}
finalizeStepName={finalize.finalizeStepName}
phase={plan.phase}
/>
)}
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-900">
{plan.peTenGoiThau ?? '(chưa có tên gói thầu)'}
</span>
</div>
</div>
<span
className={cn(
'shrink-0 rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
KHKK_PHASE_BADGE[plan.phase],
)}
>
{KHKK_PHASE_LABELS[plan.phase]}
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{plan.maKeHoach ?? '(chưa cấp mã)'}</span>
{plan.approvalGroup != null && (
<span className="rounded border border-violet-200 bg-violet-50 px-1 text-[9px] font-semibold text-violet-700">
N{plan.approvalGroup}
</span>
)}
<span>·</span>
<span className="truncate">{formatDateTime(plan.createdAt)}</span>
</div>
<div className="mt-0.5 flex items-center justify-between gap-2 text-[11px] text-slate-500">
<span className="min-w-0 truncate">👤 {plan.drafterFullName ?? '—'}</span>
<span className={cn('shrink-0 tabular-nums', frozen ? 'text-emerald-700' : 'text-brand-800')}>
{formatVnd(amount)}
</span>
</div>
</button>
)
}
export function KhkkListPanel({
selectedId,
onSelect,
search,
onSearchChange,
phase,
onPhaseChange,
group,
onGroupChange,
deletedView,
page,
onPageChange,
selectedFinalize,
}: {
selectedId: string | null
onSelect: (id: string) => void
search: string
onSearchChange: (v: string) => void
phase: KhkkPhaseValue | null
onPhaseChange: (v: KhkkPhaseValue | null) => void
group: number | null
onGroupChange: (v: number | null) => void
deletedView: boolean
page: number
onPageChange: (p: number) => void
selectedFinalize?: { endedByLevelFinalize: boolean; finalizeStepName: string | null }
}) {
const { canPe } = usePipelineStages()
const term = search.trim()
// [R-11] Lọc đi SERVER-SIDE, KHÔNG lọc trên cây: có từ khoá / có trạng-thái / xem thùng
// rác ⇒ chuyển sang danh-sách phẳng có phân-trang thật (`total` từ server) nên phiếu nằm
// ngoài 200 phiếu mới nhất VẪN tới được.
// [M-1] `!canPe` ⇒ cũng dùng đúng danh-sách phẳng này — cây khoá không được phép làm
// người dùng mất trắng phiếu của chính họ.
const flatMode = !canPe || deletedView || term.length > 0 || phase !== null
const list = useQuery({
queryKey: ['khkk-list', { group, phase, search: term, page, deletedView }],
queryFn: async () => {
const url = deletedView ? '/contract-signing-plans/deleted' : '/contract-signing-plans'
return (await api.get<PagedResult<KhkkListItemDto>>(url, {
params: {
phase: deletedView ? undefined : (phase ?? undefined),
group: group ?? undefined,
search: term || undefined,
page,
pageSize: PAGE_SIZE,
},
})).data
},
enabled: flatMode,
})
const items = list.data?.items ?? []
const total = list.data?.total ?? 0
const totalPages = list.data?.totalPages ?? 1
// [B1] Thân folder GĐ2 của cây = CARD phiếu của chính trang này; `Select` nhóm lọc luôn
// trên cây (client-side, an toàn: đây chỉ là chia lại đúng tập phiếu cây đã có).
const stage2Content = (plans: KhkkListItemDto[]) => {
const shown = group === null ? plans : plans.filter(k => k.approvalGroup === group)
if (shown.length === 0) {
return (
<p className="px-3 py-2 text-[11px] italic text-slate-400">
{group === null ? 'Gói này chưa có kế hoạch ký kết.' : `Gói này chưa có phiếu nhóm N${group}.`}
</p>
)
}
return (
<ul className="divide-y divide-slate-100">
{shown.map(k => (
<li key={k.id}>
<KhkkPlanCard
plan={k}
selected={selectedId === k.id}
onSelect={onSelect}
finalize={selectedId === k.id ? selectedFinalize : undefined}
/>
</li>
))}
</ul>
)
}
const stage2Count = (plans: KhkkListItemDto[]) =>
(group === null ? plans : plans.filter(k => k.approvalGroup === group)).length
return (
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
{/* Khối filter — CHỖ ĐẶT giống PE/GĐ3 (trên cùng panel 1). `Select` nhóm N1-N8 là
chỗ KHKK khác nguồn dữ-liệu chứ không khác bố-trí (P1-4). */}
<div className="space-y-2 border-b border-slate-200 p-3">
<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ã kế hoạch / mã phiếu / gói thầu…"
className="pl-8"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Select
value={phase ?? ''}
aria-label="Lọc trạng thái"
onChange={e => onPhaseChange(e.target.value === '' ? null : (Number(e.target.value) as KhkkPhaseValue))}
>
<option value="">Tất cả trạng thái</option>
{(Object.keys(KHKK_PHASE_LABELS) as unknown as number[]).map(p => (
<option key={p} value={p}>{KHKK_PHASE_LABELS[Number(p) as KhkkPhaseValue]}</option>
))}
</Select>
<Select
value={group ?? ''}
aria-label="Lọc nhóm duyệt"
onChange={e => onGroupChange(e.target.value === '' ? null : Number(e.target.value))}
>
<option value="">Tất cả nhóm</option>
{KHKK_APPROVAL_GROUPS.map(n => (
<option key={n} value={n}>{khkkGroupMenuLabel(n)}</option>
))}
</Select>
</div>
{flatMode && !deletedView && canPe && (
<p className="text-[10px] italic text-slate-400">
Đang lọc danh sách lấy thẳng từ máy chủ (không giới hạn 200 phiếu của cây).
</p>
)}
{!canPe && (
<p className="text-[10px] italic text-slate-400">
Bạn chưa quyền xem phiếu Duyệt NCC nên cây toàn trình không dựng đưc danh
sách kế hoạch kết vẫn liệt đy đ dưới.
</p>
)}
</div>
<div className="flex-1 overflow-y-auto">
{!flatMode && (
<PipelineTreePanel
currentStage={2}
className="border-0 shadow-none"
bodyMaxHClass="max-h-[28rem] lg:max-h-none"
stage2Options={{ stage2Content, stage2Count, onOpenKhkk: onSelect }}
/>
)}
{flatMode && list.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100 motion-reduce:animate-none" />
))}
</div>
)}
{flatMode && list.isError && (
<div className="space-y-2 px-3 py-4">
<p className="text-[12px] text-slate-500">Không tải đưc danh sách kế hoạch kết.</p>
<button
type="button"
onClick={() => list.refetch()}
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700"
>
Thử lại
</button>
</div>
)}
{flatMode && !list.isLoading && !list.isError && items.length === 0 && (
<div className="p-6 text-center text-sm 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 khớp bộ lọc.'}
</div>
)}
{flatMode && items.length > 0 && (
<ul className="divide-y divide-slate-100">
{items.map(k => (
<li key={k.id} className={cn(deletedView && 'opacity-70')}>
<KhkkPlanCard
plan={k}
selected={selectedId === k.id}
onSelect={deletedView ? () => { /* phiếu đã xóa: chỉ liệt kê, không mở */ } : onSelect}
finalize={selectedId === k.id ? selectedFinalize : undefined}
/>
</li>
))}
</ul>
)}
</div>
{flatMode && totalPages > 1 && (
<div className="flex shrink-0 items-center justify-between border-t border-slate-200 px-3 py-2 text-[11px]">
<span className="text-slate-500">
<FileCheck className="mr-1 inline h-3 w-3" />
{total} kế hoạch Trang {page} / {totalPages}
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1}
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="rounded border border-slate-300 px-2 py-0.5 text-slate-600 hover:bg-slate-50 disabled:opacity-40"
>
</button>
</div>
</div>
)}
</aside>
)
}