[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
|
// 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ì".
|
// 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 { useQuery } from '@tanstack/react-query'
|
||||||
import { ChevronRight, FileCheck, FileSignature, FolderTree, Lock, Pencil } from 'lucide-react'
|
import { FileSignature, FolderTree, Lock, Pencil } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
|
||||||
import { usePermission } from '@/hooks/usePermission'
|
import { usePermission } from '@/hooks/usePermission'
|
||||||
|
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import {
|
import {
|
||||||
@ -34,15 +34,11 @@ import {
|
|||||||
KHKK_PHASE_LABELS,
|
KHKK_PHASE_LABELS,
|
||||||
KhkkPhase,
|
KhkkPhase,
|
||||||
type KhkkListItemDto,
|
type KhkkListItemDto,
|
||||||
type KhkkPeCandidateDto,
|
|
||||||
type PagedResult,
|
type PagedResult,
|
||||||
} from '@/types/khkk'
|
} from '@/types/khkk'
|
||||||
|
|
||||||
const KHKK_TIER_PAGE_SIZE = 30
|
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
|
/** 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ệ. */
|
* `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({
|
export function KhkkWorkListPanel({
|
||||||
onPickPe,
|
onPickPe,
|
||||||
onPickPlan,
|
onPickPlan,
|
||||||
selectedPeId,
|
|
||||||
selectedPlanId,
|
selectedPlanId,
|
||||||
onEditPlan,
|
onEditPlan,
|
||||||
group,
|
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
|
/** 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". */
|
* 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
|
onPickPlan: (planId: string) => void
|
||||||
selectedPeId?: string | null
|
|
||||||
selectedPlanId?: 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 ⇒
|
/** [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ũ). */
|
* 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. */
|
* N1/N7 la doc nham "nhom nay dang co viec". `null` = khong loc. */
|
||||||
group?: number | null
|
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()
|
const { can } = usePermission()
|
||||||
// 🔴 ĐÚNG key policy của endpoint — KHÔNG dùng `Khkk_*` (đó là key MENU, tầng hiển thị).
|
// 🔴 ĐÚ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 canKhkk = can('KeHoachKyKet', 'Read')
|
||||||
|
|
||||||
const candidates = useQuery({
|
// 🧊 [S175] Query `khkk-pe-candidates` ĐÃ GỠ khỏi panel này — tầng trên nay là
|
||||||
// Key dùng CHUNG với `KhkkCreatePage` là CỐ Ý: tạo kế hoạch xong quay lại đây thì
|
// `PipelineTreePanel`, nó TỰ nạp cây từ index PE của riêng nó. Giữ query cũ = gọi mạng
|
||||||
// cột tự tươi, không phải nhớ invalidate thêm một key thứ hai.
|
// cho một danh sách không còn ai vẽ.
|
||||||
queryKey: ['khkk-pe-candidates'],
|
// 🔸 `KhkkCreatePage` VẪN dùng key này cho ô chọn PE của form tạo — cache dùng chung,
|
||||||
queryFn: async () =>
|
// nên gỡ ở đây không làm form tạo mất dữ-liệu.
|
||||||
(await api.get<KhkkPeCandidateDto[]>('/contract-signing-plans/approved-pe-awaiting-plan')).data,
|
|
||||||
enabled: canKhkk,
|
|
||||||
})
|
|
||||||
|
|
||||||
const plans = useQuery({
|
const plans = useQuery({
|
||||||
queryKey: ['khkk-list', { side: true, page: 1, pageSize: KHKK_TIER_PAGE_SIZE }],
|
queryKey: ['khkk-list', { side: true, page: 1, pageSize: KHKK_TIER_PAGE_SIZE }],
|
||||||
@ -124,30 +111,7 @@ export function KhkkWorkListPanel({
|
|||||||
enabled: canKhkk,
|
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
|
// [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:
|
// 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>
|
</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
|
<Tier
|
||||||
title="Đã duyệt · chưa lập KHKK"
|
title="Cây toàn trình — bấm phiếu để lập kế hoạch"
|
||||||
count={awaiting.length}
|
count={null}
|
||||||
accent="bg-amber-50 text-amber-600"
|
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>}
|
<PipelineTreePanel
|
||||||
{candidates.isError && (
|
currentStage={2}
|
||||||
<div className="space-y-1.5 p-3">
|
hideHeader
|
||||||
<p className="text-[11px] text-slate-500">Không tải được danh sách.</p>
|
hideSearch
|
||||||
<button
|
bodyMaxHClass="max-h-none"
|
||||||
type="button"
|
stage2Options={{ onOpenPe: onPickPe, onOpenKhkk: onPickPlan }}
|
||||||
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} | |||||||