[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
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 cua1b3bcaa(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 vs1b3bcaa) + control-duong grep 0 hit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
285
fe-admin/src/components/khkk/KhkkWorkListPanel.tsx
Normal file
285
fe-admin/src/components/khkk/KhkkWorkListPanel.tsx
Normal 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 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-400">
|
||||
Mọi phiếu đã duyệt đều đã có kế hoạch ký 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 có kế hoạch ký 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ý kết HĐ.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user