[CLAUDE] Contract: man KHKK Thao tac thanh host 2-panel giong Duyet NCC + REVERT cot da chen nham vao man PE
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m40s

Anh bac ban truoc: "Bo phan nay di cho duyet NCC da golive va da tot roi, y tao la dang
muon may lam giong giao dien thao tac cua Duyet NCC".

REVERT (quan trong nhat)
- `PurchaseEvaluationWorkspacePage` x2 app tro ve DUNG BYTE cua 1b3bcaa (grid 2 panel,
  0 import, 0 markup thua). Verify bang sha256 vs `git show 1b3bcaa:` + control-duong
  grep 0 hit ten panel trong pages/pe/.
- Bai hoc ghi vao dau file panel: "lam giong man X" != "sua man X". Ban dau doc anh 2/3
  (anh khoanh o man PE) thanh "chen them cot vao man PE"; anh 7 moi lo cho dat that la
  cot TRAI cua man Thao tac KHKK.

DI DOI
- `components/pe/PeKhkkSidePanel.tsx` -> `components/khkk/KhkkWorkListPanel.tsx` (git mv,
  giu lich su) — no phuc vu KHKK chu khong phai PE.

MAN KHKK — THAO TAC (`/khkk/create`, 8 leaf nhom) doi tu trang-giay-cuon sang HOST 2-PANEL
- Khuon be nguyen tu `PurchaseEvaluationWorkspacePage`: header 1 dong + grid
  `lg:grid-cols-[300px_1fr]` + Panel1 danh sach + nut dinh day + Panel2 noi dung.
- Panel 1 = 2 TANG dung anh anh khoanh:
    tang TREN "Cot danh sach Duyet NCC" — PE da duyet CHUA lap KHKK, gom theo DU AN;
                                          bam = nap thang vao form tao (tao luon KHKK).
    tang DUOI "Cot KHKK da tao"        — bam = XEM tai cho, read-only.
- 🔴 KHONG re-direct o BAT KY duong nao (anh chot "cho hien phieu do luon, chi view la dc,
  ko re-direct ve phieu goc"): panel nhan CALLBACK onPickPe/onPickPlan thay vi tu navigate.
  Van con nut phu "Mo trang day du" cho ai muon roi man — la lua chon, khong phai ep buoc.
- Section 1 cua form doi tu DANH SACH CHON thanh KHOI XAC NHAN phieu dang chon — danh sach
  da o Panel 1, de 2 cho chon cung 1 thu = 2 nguon su that tren cung mot man.
- Tao xong KHONG navigate('/khkk/:id') nua ma mo luon phieu vua tao o Panel 2 + tra form ve
  trang. Cung tinh than "khong roi man".
- Nut "Them moi" (dinh day Panel 1) = tra Panel 2 ve form trang.
- Gate `KeHoachKyKet.Read` giu nguyen trong panel (gotcha #85); thieu quyen thi NOI RA,
  va nut hanh-dong VAN hien de man khong thanh ngo cut.

Verify: build 2 app PASS; mirror SHA256 3/3 cap KHOP; PE workspace 0 du luong (byte-exact
vs 1b3bcaa) + control-duong grep 0 hit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-05 15:26:35 +07:00
parent e6347fd42d
commit 3f339c02c6
6 changed files with 662 additions and 560 deletions

View File

@ -0,0 +1,285 @@
// [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 { FileCheck, FileSignature, Lock } from 'lucide-react'
import { usePermission } from '@/hooks/usePermission'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
KHKK_PHASE_BADGE,
KHKK_PHASE_LABELS,
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,
footer,
}: {
/** 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
/** Nút "+ Thêm mới" dính đáy — mirror `PeListPanel` của màn Duyệt NCC — Thao tác. */
footer?: React.ReactNode
}) {
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)
// Gom theo DỰ ÁN — đúng chữ "theo dự án" của anh. `Map` giữ thứ tự chèn nên dự án nào có
// phiếu mới nhất (BE trả sẵn theo thứ tự) thì nổi lên trước.
const byProject = new Map<string, KhkkPeCandidateDto[]>()
for (const pe of awaiting) {
const key = pe.projectName ?? '(chưa gán dự án)'
const bucket = byProject.get(key)
if (bucket) bucket.push(pe)
else byProject.set(key, [pe])
}
const planItems = plans.data?.items ?? []
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 quyền xem Kế hoạch kết 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-400">
Mọi phiếu đã duyệt đu đã kế hoạch kết.
</p>
)}
{[...byProject.entries()].map(([projectName, items]) => (
<div key={projectName}>
<div className="sticky top-0 z-10 flex items-center gap-1.5 border-b border-slate-100 bg-white/95 px-3 py-1 backdrop-blur">
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{projectName}
</span>
<span className="shrink-0 text-[10px] text-slate-400">{items.length}</span>
</div>
<ul className="divide-y divide-slate-100">
{items.map((pe) => (
<li key={pe.id}>
<button
type="button"
// Bấm = nạp THẲNG phiếu này vào form tạo bên panel 2. Không điều hướng,
// không mở lại màn khác — đúng "click vào cột này để tạo luôn KHKK".
onClick={() => onPickPe(pe.id)}
title="Lập kế hoạch ký kết cho phiếu này"
className={cn(
'block w-full px-3 py-2 text-left transition hover:bg-amber-50/60',
selectedPeId === pe.id && 'bg-amber-50 ring-1 ring-inset ring-amber-300',
)}
>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] text-brand-700">{pe.maPhieu ?? '—'}</span>
<span className="text-[10px] text-slate-400">·</span>
<span className="truncate text-[10px] text-slate-500">{formatDate(pe.createdAt)}</span>
</div>
<div className="truncate text-[12px] font-medium text-slate-800">{pe.tenGoiThau}</div>
{pe.winnerSupplierNames.length > 0 && (
<div className="mt-0.5 truncate text-[10px] text-emerald-700">
{pe.winnerSupplierNames.join(' · ')}
</div>
)}
</button>
</li>
))}
</ul>
</div>
))}
</Tier>
<div className="shrink-0 border-t-2 border-slate-200" />
<Tier
title="Kế hoạch ký kết đã tạo"
count={plans.data?.total ?? null}
accent="bg-violet-50 text-violet-600"
icon={<FileSignature className="h-3 w-3" />}
>
{plans.isLoading && <div className="p-3 text-[11px] text-slate-500">Đang tải</div>}
{plans.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={() => plans.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>
)}
{!plans.isLoading && !plans.isError && planItems.length === 0 && (
<p className="p-3 text-[11px] italic text-slate-400">Chưa kế hoạch kết nào.</p>
)}
<ul className="divide-y divide-slate-100">
{planItems.map((k) => (
<li key={k.id}>
<button
type="button"
// 🔴 CHỈ XEM tại chỗ — KHÔNG `navigate('/khkk/:id')`. Anh chốt: "cho hiện
// phiếu đó luôn, chỉ view là đc, ko re-direct về phiếu gốc".
onClick={() => onPickPlan(k.id)}
title="Xem kế hoạch ký kết này (chỉ xem)"
className={cn(
'block w-full px-3 py-2 text-left transition hover:bg-violet-50/60',
selectedPlanId === k.id && 'bg-violet-50 ring-1 ring-inset ring-violet-300',
)}
>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] text-violet-700">{k.maKeHoach ?? '(chưa cấp mã)'}</span>
{k.approvalGroup != null && (
<span className="rounded border border-violet-200 bg-violet-50 px-1 text-[9px] font-semibold text-violet-700">
N{k.approvalGroup}
</span>
)}
<span
className={cn(
'ml-auto shrink-0 rounded border px-1.5 py-0.5 text-[9px] font-medium',
KHKK_PHASE_BADGE[k.phase],
)}
>
{KHKK_PHASE_LABELS[k.phase]}
</span>
</div>
<div className="truncate text-[12px] font-medium text-slate-800">
{k.peTenGoiThau ?? '(chưa có tên gói thầu)'}
</div>
</button>
</li>
))}
</ul>
{plans.data && plans.data.total > planItems.length && (
<p className="border-t border-slate-100 px-3 py-1.5 text-[10px] italic text-slate-400">
Hiện {planItems.length}/{plans.data.total} kế hoạch mới nhất xem đ menu Kế hoạch kết .
</p>
)}
</Tier>
</>
)}
{/* Nút hành-động dính ĐÁY, ngoài vùng cuộn của 2 tầng — mirror "+ Thêm mới" của
`PeListPanel` (màn Duyệt NCC — Thao tác). Hiện KỂ CẢ khi thiếu quyền KHKK: cột
không dựng được thì vẫn phải còn đường bấm, không thì màn thành ngõ cụt. */}
{footer && <div className="shrink-0 border-t border-slate-200 p-2">{footer}</div>}
</aside>
)
}