[CLAUDE] FE-Admin: KHKK man Thao tac dung LAI cay Nam>Du an>Hang muc>4 GD (FE-only, 0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m25s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m25s
Anh: "cho hien thi no giong the nay nhe" (chi sang cay o man Danh sach) + "Hien thi theo ngay thang du an hang muc phieu duyet ncc va phieu KHKK tuong ung". === LAM GI === Tang tren man Thao tac nay DUNG LAI chinh `PipelineTreePanel` — cay 📅 Nam > 📁 Du an > 🧱 Hang muc > 4 folder giai doan, dung cay anh tro sang. 🔴 DUNG LAI thay vi dung ban thu hai: cay do DA co san tang NAM + gom hang muc + 4 folder; chep lai la de 2 su-that, sua 1 cho quen cho kia. Lead vua tu tay dung cay 2 cap (Du an > Hang muc) o luot truoc — nay GO HAN, vi no la ban thu hai cua cung mot hinh. 🔴 2 callback DA CO SAN, khong phai them moi: · `onOpenPe` (them o dot D/O6) => bam phieu PE tren cay = nap THANG vao form tao, o lai man. · `onOpenKhkk` => bam phieu KHKK = mo o panel 2. Khong truyen thi cay tu `navigate` roi man — dung thu anh cam ("ko re-direct"). 🔸 `bodyMaxHClass="max-h-none"`: `Tier` da tu cuon; de tran mac-dinh cua cay thi thanh 2 thanh cuon long nhau. === DON DEAD CODE (do go cay tu-dung) === · query `khkk-pe-candidates` GO khoi panel nay — cay tu nap index PE cua no; giu lai la goi mang cho danh sach khong con ai ve. 🔸 `KhkkCreatePage` VAN dung key nay cho o chon PE cua form tao => cache dung chung, form tao KHONG mat du-lieu. · go `awaiting` / `byProject` / `countProject` / `collapsed` / `formatDate` / prop `selectedPeId` — tat ca chi ton tai de phuc vu cay tu-dung. === HAU-KIEM host khac === 4 host cay cua module Hop dong (ContractsListPage · HardCopiesPage x2 · MyContractsPage): `onOpenPe` = 0, `hideSearch` = 0 => KHONG doi hanh vi. Ca 2 prop deu opt-in. Verify dist bang NOI DUNG (khong tin thoi-gian build): chuoi moi 'Cay toan trinh — bam phieu' = 1/1 ca 2 app; chuoi CU 'Da duyet · chua lap KHKK' = 0/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@ -24,9 +24,9 @@
|
||||
// con). Người chỉ có quyền PE mà không có KHKK sẽ ăn 403 rải khắp cột. ⇒ gate bằng ĐÚNG key
|
||||
// của endpoint, và khi thiếu quyền thì NÓI RA thay vì để cột trắng trông như "chưa có gì".
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ChevronRight, FileCheck, FileSignature, FolderTree, Lock, Pencil } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { FileSignature, FolderTree, Lock, Pencil } from 'lucide-react'
|
||||
import { usePermission } from '@/hooks/usePermission'
|
||||
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
@ -34,15 +34,11 @@ import {
|
||||
KHKK_PHASE_LABELS,
|
||||
KhkkPhase,
|
||||
type KhkkListItemDto,
|
||||
type KhkkPeCandidateDto,
|
||||
type PagedResult,
|
||||
} from '@/types/khkk'
|
||||
|
||||
const KHKK_TIER_PAGE_SIZE = 30
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
/** Vỏ 1 tầng — `min-h-0 flex-1` ở CẢ HAI tầng là thứ tạo ra tỉ lệ 50/50 thật; thiếu
|
||||
* `min-h-0` thì con cuộn được sẽ đẩy tầng phình theo nội dung, hỏng tỉ lệ. */
|
||||
@ -78,7 +74,6 @@ function Tier({
|
||||
export function KhkkWorkListPanel({
|
||||
onPickPe,
|
||||
onPickPlan,
|
||||
selectedPeId,
|
||||
selectedPlanId,
|
||||
onEditPlan,
|
||||
group,
|
||||
@ -88,7 +83,6 @@ export function KhkkWorkListPanel({
|
||||
/** Bấm KHKK ở tầng dưới → host mở phiếu đó ở panel 2, CHỈ XEM. Anh chốt: "click vào phiếu
|
||||
* nào thì cho hiện phiếu đó luôn, chỉ view là đc, ko re-direct về phiếu gốc". */
|
||||
onPickPlan: (planId: string) => void
|
||||
selectedPeId?: string | null
|
||||
selectedPlanId?: string | null
|
||||
/** [S175 · O7] Bấm bút chì trên card KHKK → host mở phiếu ở chế-độ SỬA. Không truyền ⇒
|
||||
* không vẽ bút chì (cột giữ đúng hình cũ). */
|
||||
@ -98,22 +92,15 @@ export function KhkkWorkListPanel({
|
||||
* N1/N7 la doc nham "nhom nay dang co viec". `null` = khong loc. */
|
||||
group?: number | null
|
||||
}) {
|
||||
// [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Tập KHOÁ đang GẬP.
|
||||
// Dùng tập-gập (không phải tập-mở) để mặc-định là MỞ HẾT: dự án/hạng mục mới xuất hiện sau
|
||||
// này tự mở, không bị ẩn im lặng vì chưa ai bấm mở bao giờ.
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||||
const { can } = usePermission()
|
||||
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị).
|
||||
const canKhkk = can('KeHoachKyKet', 'Read')
|
||||
|
||||
const candidates = useQuery({
|
||||
// Key dùng CHUNG với `KhkkCreatePage` là CỐ Ý: tạo kế hoạch xong quay lại đây thì
|
||||
// cột tự tươi, không phải nhớ invalidate thêm một key thứ hai.
|
||||
queryKey: ['khkk-pe-candidates'],
|
||||
queryFn: async () =>
|
||||
(await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data,
|
||||
enabled: canKhkk,
|
||||
})
|
||||
// 🧊 [S175] Query `khkk-pe-candidates` ĐÃ GỠ khỏi panel này — tầng trên nay là
|
||||
// `PipelineTreePanel`, nó TỰ nạp cây từ index PE của riêng nó. Giữ query cũ = gọi mạng
|
||||
// cho một danh sách không còn ai vẽ.
|
||||
// 🔸 `KhkkCreatePage` VẪN dùng key này cho ô chọn PE của form tạo — cache dùng chung,
|
||||
// nên gỡ ở đây không làm form tạo mất dữ-liệu.
|
||||
|
||||
const plans = useQuery({
|
||||
queryKey: ['khkk-list', { side: true, page: 1, pageSize: KHKK_TIER_PAGE_SIZE }],
|
||||
@ -124,30 +111,7 @@ export function KhkkWorkListPanel({
|
||||
enabled: canKhkk,
|
||||
})
|
||||
|
||||
// "Chưa được tạo phiếu KHKK" = chưa dùng nhóm duyệt NÀO. `?? []` phòng payload cũ còn
|
||||
// trong cache React Query (DTO trước K2 không có field này) — thiếu guard thì `.length` nổ.
|
||||
const awaiting = (candidates.data ?? []).filter((p) => (p.existingGroups ?? []).length === 0)
|
||||
|
||||
// [S175 · O8] CÂY 2 CẤP **Dự án › Hạng mục** — anh chốt "cho cây thư mục dự án hạng mục
|
||||
// ra luôn nhé". Trước đây chỉ gom 1 cấp theo dự án, nên một dự án nhiều gói thầu đổ ra
|
||||
// một danh sách phẳng dài, không thấy được ranh giới hạng mục.
|
||||
// 🔴 0 ENDPOINT MỚI: `workItemName` ĐÃ có sẵn trong `KhkkPeCandidateDto` (`khkk.ts:423`)
|
||||
// ⇒ cấp 2 là gom lại trên dữ-liệu đang có, không phải gọi thêm cửa nào.
|
||||
// `Map` giữ thứ tự chèn ở CẢ HAI cấp nên dự án/hạng mục có phiếu mới nhất nổi lên trước
|
||||
// (BE đã trả sẵn theo thứ tự).
|
||||
const byProject = new Map<string, Map<string, KhkkPeCandidateDto[]>>()
|
||||
for (const pe of awaiting) {
|
||||
const pKey = pe.projectName ?? '(chưa gán dự án)'
|
||||
const wKey = pe.workItemName ?? '(chưa gán hạng mục)'
|
||||
let byWorkItem = byProject.get(pKey)
|
||||
if (!byWorkItem) { byWorkItem = new Map(); byProject.set(pKey, byWorkItem) }
|
||||
const bucket = byWorkItem.get(wKey)
|
||||
if (bucket) bucket.push(pe)
|
||||
else byWorkItem.set(wKey, [pe])
|
||||
}
|
||||
/** Đếm phiếu của cả một dự án (cộng mọi hạng mục con) — số ở header cấp 1. */
|
||||
const countProject = (byWorkItem: Map<string, KhkkPeCandidateDto[]>) =>
|
||||
[...byWorkItem.values()].reduce((s, arr) => s + arr.length, 0)
|
||||
|
||||
// [S174 owner] "chỗ thao tác chỉ hiển trả lại và nháp, từ chối thôi nhé" ⇒ DANH-SÁCH-TRẮNG
|
||||
// 3 trạng thái, KHÔNG phải loại-trừ. 🔴 Đổi từ loại-trừ sang danh-sách-trắng là CÓ CHỦ ĐÍCH:
|
||||
@ -179,115 +143,30 @@ export function KhkkWorkListPanel({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* [S175 — anh: "cho hiển thị nó giống thế này nhé" + "Hiển thị theo ngày tháng dự
|
||||
án hạng mục phiếu duyệt ncc và phiếu KHKK tương ứng"] Tầng trên nay DÙNG LẠI
|
||||
CHÍNH `PipelineTreePanel` — cây 📅 Năm › 📁 Dự án › 🧱 Hạng mục › 4 giai đoạn,
|
||||
đúng cây anh chỉ sang ở màn Danh sách.
|
||||
🔴 DÙNG LẠI thay vì dựng bản thứ hai: cây đó đã có sẵn tầng NĂM + gom hạng mục +
|
||||
4 folder giai đoạn; chép lại là đẻ 2 sự-thật, sửa 1 chỗ quên chỗ kia.
|
||||
🔴 2 callback đã có sẵn từ đợt trước: `onOpenPe` (O6) → nạp PE thẳng vào form tạo ·
|
||||
`onOpenKhkk` → mở phiếu KHKK ở panel 2. Không truyền thì cây tự `navigate` rời màn
|
||||
— đúng thứ anh cấm ("ko re-direct").
|
||||
🔸 `bodyMaxHClass="max-h-none"`: `Tier` đã tự cuộn rồi, để trần mặc-định của cây
|
||||
thì thành 2 thanh cuộn lồng nhau. */}
|
||||
<Tier
|
||||
title="Đã duyệt · chưa lập KHKK"
|
||||
count={awaiting.length}
|
||||
title="Cây toàn trình — bấm phiếu để lập kế hoạch"
|
||||
count={null}
|
||||
accent="bg-amber-50 text-amber-600"
|
||||
icon={<FileCheck className="h-3 w-3" />}
|
||||
icon={<FolderTree className="h-3 w-3" />}
|
||||
>
|
||||
{candidates.isLoading && <div className="p-3 text-[11px] text-slate-500">Đang tải…</div>}
|
||||
{candidates.isError && (
|
||||
<div className="space-y-1.5 p-3">
|
||||
<p className="text-[11px] text-slate-500">Không tải được danh sách.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => candidates.refetch()}
|
||||
className="rounded-md border border-slate-300 px-2 py-1 text-[10px] font-semibold text-slate-600 hover:border-brand-300 hover:bg-brand-50"
|
||||
>
|
||||
Thử lại
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!candidates.isLoading && !candidates.isError && awaiting.length === 0 && (
|
||||
<p className="p-3 text-[11px] italic text-slate-500">
|
||||
Mọi phiếu đã duyệt đều đã có kế hoạch ký kết.
|
||||
</p>
|
||||
)}
|
||||
{/* [S175 · O8] CẤP 1 = Dự án (sticky, `z-20`) · CẤP 2 = Hạng mục (sticky dưới nó,
|
||||
`z-10` + thụt lề + vạch trái). 🔴 Hai `z` khác nhau là BẮT BUỘC: cùng `z` thì
|
||||
header hạng mục trượt ĐÈ lên header dự án khi cuộn, mất mất cấp cha. */}
|
||||
{[...byProject.entries()].map(([projectName, byWorkItem]) => {
|
||||
const pCollapsed = collapsed.has(projectName)
|
||||
return (
|
||||
<div key={projectName}>
|
||||
{/* [S175 — anh: "Cho hiển thị theo cây nằm dự án hạng mục"] Header CẤP 1 nay là
|
||||
NÚT gập/mở. Mặc-định MỞ: gập sẵn thì mở panel ra thấy toàn thư-mục đóng,
|
||||
người dùng tưởng chưa có việc nào. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(projectName)) next.delete(projectName); else next.add(projectName)
|
||||
return next
|
||||
})}
|
||||
aria-expanded={!pCollapsed}
|
||||
className="sticky top-0 z-20 flex w-full items-center gap-1.5 border-b border-slate-200 bg-slate-50/95 px-3 py-1 text-left backdrop-blur transition hover:bg-slate-100"
|
||||
>
|
||||
<ChevronRight className={cn('h-3 w-3 shrink-0 text-slate-400 transition-transform', !pCollapsed && 'rotate-90')} aria-hidden />
|
||||
<FolderTree className="h-3 w-3 shrink-0 text-slate-400" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wide text-slate-600">
|
||||
{projectName}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-slate-500">{countProject(byWorkItem)}</span>
|
||||
</button>
|
||||
{!pCollapsed && [...byWorkItem.entries()].map(([workItemName, items]) => {
|
||||
const wKey = `${projectName} | ||||