[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
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:
266
fe-user/src/hooks/usePipelineStages.ts
Normal file
266
fe-user/src/hooks/usePipelineStages.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user