[CLAUDE] FE-User+FE-Admin: cay 4 folder GD duoi tung goi thau (Duyet NCC / Ke hoach HD / Duyet HD / HD cung) x2 app + 5 fix review F-1/3/4/6/7
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m50s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-30 10:04:32 +07:00
parent 8eba1379b2
commit bfc7b79f72
17 changed files with 1839 additions and 12 deletions

View File

@ -0,0 +1,266 @@
// [S162 — 2026-07-30] Nguồn dữ liệu cho cây 4 folder giai đoạn
// (`components/pipeline/PipelineStageFolders.tsx`).
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// 🔴 PERMISSION 2 TẦNG (bài S118 / gotcha #82): tầng hiển thị (menu CanRead) và
// tầng API-authz ĐỘC LẬP. Ở đây gate TẦNG HIỂN THỊ **trước khi fetch** — không
// quyền ⇒ `enabled:false` ⇒ KHÔNG gọi API (tránh rác 403 trên mọi gói thầu),
// folder hiện dạng khoá + "Sắp". Query lỗi ⇒ `failed` ⇒ đếm "—", cây KHÔNG vỡ.
//
// ⚠️ Gate theo ĐÚNG tầng của endpoint [F-1 S162]: GĐ2 `/contract-signing-plans`
// có policy per-action `KeHoachKyKet.Read` (ContractSigningPlansController.cs:36)
// và `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key — KHÔNG kế thừa,
// KHÔNG OR key con ⇒ gate GĐ2 = đúng key đó (OR `Khkk_List…` sẽ fetch rồi ăn
// 403 khi role thiếu key root). GĐ3/4 `/contracts` chỉ `[Authorize]` trần (#82)
// ⇒ OR key HIỂN-THỊ `Ct_*`/`Hdc_*` vô hại (không có policy per-action để đá).
// Menu-tree fallback quyền cha CHỈ khi menu con KHÔNG có row riêng
// (`GetMyMenuTreeQuery.cs:66`) — seeder TẠO row `Ct_*`/`Khkk_*`/`Hdc_*` riêng
// (DbInitializer.cs:2168-2176/2187/2423) ⇒ thực tế KHÔNG có kế thừa nào ở đây.
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import type { ReactNode } from 'react'
import { api } from '@/lib/api'
import { usePermission } from '@/hooks/usePermission'
import type { PipelineStage } from '@/components/pipeline/PipelineStageFolders'
import { ContractPhaseColor, ContractPhaseLabel, type ContractListItem } from '@/types/contracts'
import { KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, type KhkkListItemDto, type PagedResult } from '@/types/khkk'
import type { Paged } from '@/types/master'
import {
PeDisplayStatusColor,
PeDisplayStatusLabel,
getPeDisplayStatus,
type PeListItem,
} from '@/types/purchaseEvaluation'
// `PagedResult.cs:20` — pageSize kẹp ≤200 (không 400). Index nạp 1 phát rồi
// nối cây client-side; KHÔNG N+1 theo từng gói thầu.
const INDEX_PAGE_SIZE = 200
const INDEX_STALE_MS = 60_000
// `MenuKeys.cs:34` ContractTypeCodes — dùng cho Ct_*_List (GĐ3) và Hdc_* (GĐ4).
const CONTRACT_TYPE_CODES = [
'ThauPhu', 'GiaoKhoan', 'NhaCungCap', 'DichVu', 'MuaBan', 'NguyenTacNcc', 'NguyenTacDv',
] as const
const PE_MENU_KEYS = ['PurchaseEvaluations', 'Pe_DuyetNcc_List', 'Pe_DuyetNccPhuongAn_List']
// [F-1 S162] = đúng key policy của endpoint GĐ2 (xem ⚠️ đầu file).
const KHKK_MENU_KEYS = ['KeHoachKyKet']
const CONTRACT_MENU_KEYS = ['Contracts', ...CONTRACT_TYPE_CODES.map(c => `Ct_${c}_List`)]
const HARD_COPY_MENU_KEYS = ['HopDongCung', ...CONTRACT_TYPE_CODES.map(c => `Hdc_${c}`)]
export type PipelineLinkage = {
canPe: boolean
canKhkk: boolean
canContract: boolean
canHardCopy: boolean
khkkByPeId: Map<string, KhkkListItemDto[]>
contractById: Map<string, ContractListItem>
khkkFailed: boolean
contractFailed: boolean
khkkLoading: boolean
contractLoading: boolean
/** ≥1 index chạm trần cửa sổ 200 ⇒ count GĐ2-4 CÓ THỂ thiếu (khai trên cây). */
indexTruncated: boolean
}
/** Nạp 1 lần index KHKK + HĐ để nối GĐ2/3/4 vào từng gói thầu. */
export function usePipelineLinkage(): PipelineLinkage {
const { can } = usePermission()
const canPe = PE_MENU_KEYS.some(k => can(k))
const canKhkk = KHKK_MENU_KEYS.some(k => can(k))
const canContract = CONTRACT_MENU_KEYS.some(k => can(k))
const canHardCopy = HARD_COPY_MENU_KEYS.some(k => can(k))
// Query key MỚI (không dùng lại `khkk-list` / `hard-copies` / `contracts`):
// đổi shape cache dùng chung sẽ vỡ trang khác (bài S159).
const khkkIndex = useQuery({
queryKey: ['pipeline-khkk-index'],
// [F-4 S162] giữ cả `total`: chạm trần cửa sổ 200 phải KHAI trên cây
// (truncated), không để count tụt im lặng.
queryFn: async () => {
const d = (
await api.get<PagedResult<KhkkListItemDto>>('/contract-signing-plans', {
params: { page: 1, pageSize: INDEX_PAGE_SIZE },
})
).data
return { items: d.items, total: d.total }
},
enabled: canKhkk,
staleTime: INDEX_STALE_MS,
retry: 1,
})
// GĐ3 và GĐ4 cùng ăn 1 danh sách HĐ (GĐ4 = tập con `hasSealedCopy`).
const contractIndex = useQuery({
queryKey: ['pipeline-contract-index'],
queryFn: async () => {
const d = (
await api.get<Paged<ContractListItem>>('/contracts', {
params: { page: 1, pageSize: INDEX_PAGE_SIZE },
})
).data
return { items: d.items, total: d.total }
},
enabled: canContract || canHardCopy,
staleTime: INDEX_STALE_MS,
retry: 1,
})
const khkkByPeId = useMemo(() => {
const map = new Map<string, KhkkListItemDto[]>()
for (const k of khkkIndex.data?.items ?? []) {
const bucket = map.get(k.purchaseEvaluationId)
if (bucket) bucket.push(k)
else map.set(k.purchaseEvaluationId, [k])
}
for (const bucket of map.values()) {
bucket.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
}
return map
}, [khkkIndex.data])
const contractById = useMemo(() => {
const map = new Map<string, ContractListItem>()
for (const c of contractIndex.data?.items ?? []) map.set(c.id, c)
return map
}, [contractIndex.data])
return {
canPe,
canKhkk,
canContract,
canHardCopy,
khkkByPeId,
contractById,
khkkFailed: khkkIndex.isError,
contractFailed: contractIndex.isError,
// v5: `isLoading = isPending && isFetching` ⇒ query bị `enabled:false` cho
// false sẵn, không cần chèn thêm điều kiện quyền.
khkkLoading: khkkIndex.isLoading,
contractLoading: contractIndex.isLoading,
indexTruncated:
(khkkIndex.data ? khkkIndex.data.total > khkkIndex.data.items.length : false) ||
(contractIndex.data ? contractIndex.data.total > contractIndex.data.items.length : false),
}
}
export type BuildStagesOptions = {
/** GĐ1 tự vẽ (trang Duyệt NCC truyền khối leaf phiếu CŨ vào đây). */
stage1Content?: ReactNode
stage1Count?: number
}
/**
* Trả `buildStages(peItems)` — dựng 4 folder cho MỘT gói thầu (1 cặp Dự án ×
* Hạng mục) từ danh sách phiếu PE của gói đó.
* GĐ1 = chính các phiếu ấy
* GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu
* GĐ3 = HĐ trỏ bởi `pe.contractId`
* GĐ4 = đúng các HĐ ấy khi `hasSealedCopy === true`
*/
export function usePipelineStages() {
const linkage = usePipelineLinkage()
const navigate = useNavigate()
const buildStages = (peItems: PeListItem[], opts: BuildStagesOptions = {}): PipelineStage[] => {
// ── GĐ1 — Duyệt NCC ────────────────────────────────────────────────────
const stage1: PipelineStage =
opts.stage1Content !== undefined
? { n: 1, leaves: [], content: opts.stage1Content, count: opts.stage1Count ?? peItems.length }
: {
n: 1,
leaves: peItems.map(p => {
const display = getPeDisplayStatus(p.phase)
return {
id: p.id,
primary: p.maPhieu ?? '(chưa cấp mã)',
secondary: p.tenGoiThau,
badgeLabel: PeDisplayStatusLabel[display],
badgeClass: PeDisplayStatusColor[display],
// `expand=1` để mở overlay chi tiết — deep-link từ trang khác
// chạy được ở mọi bề rộng (panel giữa của trang PE ẩn dưới lg).
onOpen: () => navigate(`/purchase-evaluations?id=${p.id}&expand=1`),
}
}),
}
// ── GĐ2 — Kế hoạch Hợp đồng ────────────────────────────────────────────
const khkkLeaves = linkage.canKhkk
? peItems.flatMap(p => linkage.khkkByPeId.get(p.id) ?? []).map(k => ({
id: k.id,
primary: k.maKeHoach ?? '(chưa cấp mã)',
secondary: k.peTenGoiThau,
badgeLabel: KHKK_PHASE_LABELS[k.phase],
badgeClass: KHKK_PHASE_BADGE[k.phase],
onOpen: () => navigate(`/khkk/${k.id}`),
}))
: []
const stage2: PipelineStage = {
n: 2,
leaves: khkkLeaves,
locked: !linkage.canKhkk,
failed: linkage.khkkFailed,
loading: linkage.khkkLoading,
truncated: linkage.indexTruncated,
}
// ── GĐ3 / GĐ4 — HĐ trỏ bởi phiếu (GĐ4 = tập con đã có bản cứng) ─────────
// HĐ nằm ngoài index (list kẹp 200) vẫn được nêu bằng leaf tối giản thay vì
// biến mất im lặng — nhưng KHÔNG suy được `hasSealedCopy` ⇒ chỉ vào GĐ3.
const seen = new Set<string>()
const stage3Leaves: PipelineStage['leaves'] = []
const stage4Leaves: PipelineStage['leaves'] = []
for (const p of peItems) {
if (!p.contractId || seen.has(p.contractId)) continue
seen.add(p.contractId)
const contractId = p.contractId
const c = linkage.contractById.get(contractId)
if (linkage.canContract) {
stage3Leaves.push({
id: contractId,
// [F-3 S162] c vắng = HĐ NGOÀI cửa sổ index 200 — đừng dán nhãn
// "(chưa cấp mã)" (trộn 2 nghĩa khác nhau).
primary: c ? (c.maHopDong ?? '(chưa cấp mã)') : '(ngoài chỉ mục 200 — mở để xem)',
secondary: c?.tenHopDong ?? p.tenGoiThau,
badgeLabel: c ? ContractPhaseLabel[c.phase] : null,
badgeClass: c ? ContractPhaseColor[c.phase] : null,
onOpen: () => navigate(`/contracts/${contractId}`),
})
}
if (linkage.canHardCopy && c?.hasSealedCopy) {
stage4Leaves.push({
id: contractId,
primary: c.maHopDong ?? '(chưa cấp mã)',
secondary: c.tenHopDong,
badgeLabel: 'Đã lưu bản cứng',
badgeClass: 'bg-emerald-100 text-emerald-800',
// Trang "Hợp đồng cứng" đọc `?type=` từ URL (HardCopiesPage.tsx:64-67)
// ⇒ mở đúng nhóm loại HĐ của bộ này.
onOpen: () => navigate(`/hard-copies?type=${c.type}`),
})
}
}
const stage3: PipelineStage = {
n: 3,
leaves: stage3Leaves,
locked: !linkage.canContract,
failed: linkage.contractFailed,
loading: linkage.contractLoading,
truncated: linkage.indexTruncated,
}
const stage4: PipelineStage = {
n: 4,
leaves: stage4Leaves,
locked: !linkage.canHardCopy,
failed: linkage.contractFailed,
loading: linkage.contractLoading,
truncated: linkage.indexTruncated,
}
return [stage1, stage2, stage3, stage4]
}
return { ...linkage, buildStages }
}