[CLAUDE] FE-User: K5 SP-002 cay pipeline GD2 mo 8 sub-folder nhom (FE-only x2 app, 0-mig)
Gate PASS 0-blocker 6/6 (sub-reviewer-gate-k5.md): 8 ngan nhom tu KHKK_APPROVAL_GROUPS + khkkGroupMenuLabel (single-source K4a), phieu vao ngan theo approvalGroup + fallback "(chua phan nhom)" — bat-bien Σ bao dam bang CAU TRUC (1 mang nguon / 1 phep phan hoach / 1 cho dem); HIEN DU 8 ke ca rong; route ngan khop URL K4b /khkk/list?group=n; GD3/GD4 regression chung-duoc-la-khong (union pe.contractId giu, cho-cam lines[].contractId danh dau — no K7 Line.ContractId ghi-luon). SHA-pair x2 byte-identical (diff rong). tsc -b x2 = 0 (gate tu chay) + npm x2 PASS. Treo khong chan (gate MINOR 1-6): chu "SAP" tren ngan rong (UX owner) · a11y button- trong-summary (khuon moi) · FE 0 ha tang test (console.warn dev-only) · toKhkkLeaf x2 lan/phieu. UAT ngay dau: prod 0 phieu KHKK => 8 ngan "SAP" rong o moi goi = DUNG SPEC. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -55,9 +55,30 @@ export type PipelineLeaf = {
|
|||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [K5 S166] Ngăn con TRONG một folder giai đoạn (GĐ2 = 8 nhóm duyệt KHKK).
|
||||||
|
* 🔴 `leaves` ở đây là LEAVES THẬT (chính các phiếu con), KHÔNG phải một đường
|
||||||
|
* `content` riêng — nhờ vậy tổng số phiếu của các ngăn luôn bằng số phiếu của
|
||||||
|
* folder cha theo CẤU TRÚC, không phải nhờ ai đó nhớ cộng cho khớp (vá-9).
|
||||||
|
*/
|
||||||
|
export type PipelineGroup = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
leaves: PipelineLeaf[]
|
||||||
|
/** Bấm vào TÊN ngăn ⇒ mở trang danh sách đã lọc sẵn ngăn đó. */
|
||||||
|
onOpenAll?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export type PipelineStage = {
|
export type PipelineStage = {
|
||||||
n: PipelineStageNo
|
n: PipelineStageNo
|
||||||
|
/** Danh sách PHẲNG, là nguồn duy nhất để đếm. `groups` chỉ chia lại chính nó. */
|
||||||
leaves: PipelineLeaf[]
|
leaves: PipelineLeaf[]
|
||||||
|
/**
|
||||||
|
* Có mặt ⇒ thân folder vẽ theo NGĂN thay vì danh sách phẳng. Ngăn RỖNG vẫn
|
||||||
|
* hiện (mirror tiền lệ folder giai đoạn chưa có gì: chip "Sắp") — người dùng
|
||||||
|
* phải thấy đủ khung 8 nhóm để biết cái nào trống, chứ không phải đoán.
|
||||||
|
*/
|
||||||
|
groups?: PipelineGroup[]
|
||||||
/** Không có quyền xem module GĐ này ⇒ folder khoá + "Sắp", KHÔNG gọi API. */
|
/** Không có quyền xem module GĐ này ⇒ folder khoá + "Sắp", KHÔNG gọi API. */
|
||||||
locked?: boolean
|
locked?: boolean
|
||||||
/** Query GĐ này lỗi ⇒ đếm "—", cây KHÔNG vỡ. */
|
/** Query GĐ này lỗi ⇒ đếm "—", cây KHÔNG vỡ. */
|
||||||
@ -85,6 +106,106 @@ function SoonChip() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StageStyle = (typeof STAGE_STYLE)[PipelineStageNo]
|
||||||
|
|
||||||
|
/** Danh sách leaf phẳng — dùng chung cho thân folder LẪN thân từng ngăn (K5). */
|
||||||
|
function LeafList({ leaves, style }: { leaves: PipelineLeaf[]; style: StageStyle }) {
|
||||||
|
return (
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{leaves.map(leaf => (
|
||||||
|
<li key={leaf.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={leaf.onOpen}
|
||||||
|
disabled={!leaf.onOpen}
|
||||||
|
className={cn(
|
||||||
|
'block w-full px-3 py-2 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
|
||||||
|
leaf.onOpen ? 'hover:bg-slate-50' : 'cursor-default',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* Mã to hơn dòng phụ 1 nấc: font-mono 11px NHÌN nhỏ ngang
|
||||||
|
sans 11px nên 2 dòng bẹt như nhau (soi FD2 r1-D). */}
|
||||||
|
<span className={cn('min-w-0 flex-1 truncate font-mono text-[12px] font-medium', style.code)}>
|
||||||
|
{leaf.primary}
|
||||||
|
</span>
|
||||||
|
{leaf.badgeLabel && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
leaf.badgeClass ?? 'bg-slate-100 text-slate-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{leaf.badgeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{leaf.secondary && <div className="truncate text-[11px] text-slate-500">{leaf.secondary}</div>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [K5 S166] Một ngăn trong folder giai đoạn (GĐ2 = 1 nhóm duyệt).
|
||||||
|
* 🔴 Hai thao tác TÁCH BẠCH trên cùng một dòng: bấm vùng dòng = mở/đóng ngăn
|
||||||
|
* (hành vi mặc định của <summary>), bấm ĐÚNG TÊN ngăn = sang trang danh sách đã
|
||||||
|
* lọc sẵn nhóm đó. Nút tên phải chặn nổi bọt, nếu không mỗi lần đi trang lại
|
||||||
|
* kèm một lần đóng/mở ngăn nhấp nháy phía sau.
|
||||||
|
* Mặc định ĐÓNG: 8 ngăn mở sẵn là một bức tường chữ, mà cây này nằm ở cột trái.
|
||||||
|
*/
|
||||||
|
function GroupFolder({ group, style }: { group: PipelineGroup; style: StageStyle }) {
|
||||||
|
const count = group.leaves.length
|
||||||
|
const empty = count === 0
|
||||||
|
return (
|
||||||
|
<details className="group/grp">
|
||||||
|
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60 [&::-webkit-details-marker]:hidden">
|
||||||
|
<ChevronRight
|
||||||
|
aria-hidden
|
||||||
|
className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/grp:rotate-90 motion-reduce:transition-none"
|
||||||
|
/>
|
||||||
|
{group.onOpenAll ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
group.onOpenAll?.()
|
||||||
|
}}
|
||||||
|
title={`Mở danh sách: ${group.label}`}
|
||||||
|
// Ngăn rỗng vẫn bấm sang trang được (để lập phiếu mới cho nhóm đó)
|
||||||
|
// ⇒ vẫn là chữ đang hoạt động: slate-500 giữ đủ tương phản AA.
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 flex-1 truncate rounded text-left text-[11px] font-medium underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/60',
|
||||||
|
empty ? 'text-slate-500' : style.head,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={cn('min-w-0 flex-1 truncate text-[11px] font-medium', empty ? 'text-slate-500' : style.head)}>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{count > 0 ? (
|
||||||
|
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', style.count)}>{count}</span>
|
||||||
|
) : (
|
||||||
|
<SoonChip />
|
||||||
|
)}
|
||||||
|
</summary>
|
||||||
|
<div className="ml-3 border-l border-slate-200">
|
||||||
|
{empty ? (
|
||||||
|
<div className="px-3 py-1.5 text-[11px] italic text-slate-400">Chưa có phiếu ở nhóm này.</div>
|
||||||
|
) : (
|
||||||
|
<LeafList leaves={group.leaves} style={style} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentStage: PipelineStageNo }) {
|
function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentStage: PipelineStageNo }) {
|
||||||
// Folder của TRANG hiện tại mở sẵn; 3 folder kia đóng. Vẫn collapse/expand được.
|
// Folder của TRANG hiện tại mở sẵn; 3 folder kia đóng. Vẫn collapse/expand được.
|
||||||
const [open, setOpen] = useState(stage.n === currentStage)
|
const [open, setOpen] = useState(stage.n === currentStage)
|
||||||
@ -163,43 +284,19 @@ function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentSta
|
|||||||
</div>
|
</div>
|
||||||
) : stage.content !== undefined ? (
|
) : stage.content !== undefined ? (
|
||||||
stage.content
|
stage.content
|
||||||
|
) : stage.groups !== undefined ? (
|
||||||
|
// [K5 S166] Có ngăn ⇒ vẽ theo ngăn. Ngăn rỗng KHÔNG bị lọc bỏ ở đây:
|
||||||
|
// việc "hiện đủ 8 nhóm hay không" do bên dựng quyết (xem builder), chỗ
|
||||||
|
// này chỉ vẽ đúng thứ nhận được.
|
||||||
|
<div>
|
||||||
|
{stage.groups.map(g => (
|
||||||
|
<GroupFolder key={g.key} group={g} style={style} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : stage.leaves.length === 0 ? (
|
) : stage.leaves.length === 0 ? (
|
||||||
<div className="px-3 py-2 text-[11px] italic text-slate-400">Chưa có dữ liệu ở giai đoạn này.</div>
|
<div className="px-3 py-2 text-[11px] italic text-slate-400">Chưa có dữ liệu ở giai đoạn này.</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-slate-100">
|
<LeafList leaves={stage.leaves} style={style} />
|
||||||
{stage.leaves.map(leaf => (
|
|
||||||
<li key={leaf.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={leaf.onOpen}
|
|
||||||
disabled={!leaf.onOpen}
|
|
||||||
className={cn(
|
|
||||||
'block w-full px-3 py-2 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
|
|
||||||
leaf.onOpen ? 'hover:bg-slate-50' : 'cursor-default',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{/* Mã to hơn dòng phụ 1 nấc: font-mono 11px NHÌN nhỏ ngang
|
|
||||||
sans 11px nên 2 dòng bẹt như nhau (soi FD2 r1-D). */}
|
|
||||||
<span className={cn('min-w-0 flex-1 truncate font-mono text-[12px] font-medium', style.code)}>
|
|
||||||
{leaf.primary}
|
|
||||||
</span>
|
|
||||||
{leaf.badgeLabel && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
|
||||||
leaf.badgeClass ?? 'bg-slate-100 text-slate-600',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{leaf.badgeLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{leaf.secondary && <div className="truncate text-[11px] text-slate-500">{leaf.secondary}</div>}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
{stage.truncated && !stage.failed && !stage.loading && (
|
{stage.truncated && !stage.failed && !stage.loading && (
|
||||||
<div className="border-t border-slate-100 px-3 py-1 text-[10px] italic text-slate-400">
|
<div className="border-t border-slate-100 px-3 py-1 text-[10px] italic text-slate-400">
|
||||||
|
|||||||
@ -24,7 +24,14 @@ import { api } from '@/lib/api'
|
|||||||
import { usePermission } from '@/hooks/usePermission'
|
import { usePermission } from '@/hooks/usePermission'
|
||||||
import type { PipelineStage } from '@/components/pipeline/PipelineStageFolders'
|
import type { PipelineStage } from '@/components/pipeline/PipelineStageFolders'
|
||||||
import { ContractPhaseColor, ContractPhaseLabel, type ContractListItem } from '@/types/contracts'
|
import { ContractPhaseColor, ContractPhaseLabel, type ContractListItem } from '@/types/contracts'
|
||||||
import { KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, type KhkkListItemDto, type PagedResult } from '@/types/khkk'
|
import {
|
||||||
|
KHKK_APPROVAL_GROUPS,
|
||||||
|
KHKK_PHASE_BADGE,
|
||||||
|
KHKK_PHASE_LABELS,
|
||||||
|
khkkGroupMenuLabel,
|
||||||
|
type KhkkListItemDto,
|
||||||
|
type PagedResult,
|
||||||
|
} from '@/types/khkk'
|
||||||
import type { Paged } from '@/types/master'
|
import type { Paged } from '@/types/master'
|
||||||
import {
|
import {
|
||||||
PeDisplayStatusColor,
|
PeDisplayStatusColor,
|
||||||
@ -155,8 +162,9 @@ export type BuildStagesOptions = {
|
|||||||
* Trả `buildStages(peItems)` — dựng 4 folder cho MỘT gói thầu (1 cặp Dự án ×
|
* 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 đó.
|
* 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Đ1 = chính các phiếu ấy
|
||||||
* GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu
|
* GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu, chia thành 8 NGĂN theo
|
||||||
* GĐ3 = HĐ trỏ bởi `pe.contractId`
|
* nhóm duyệt (+ ngăn "(chưa phân nhóm)" khi có phiếu lạc) — K5
|
||||||
|
* GĐ3 = HĐ trong TẬP id của gói (xem forward-provision B3-A5 ở thân hàm)
|
||||||
* GĐ4 = đúng các HĐ ấy khi `hasSealedCopy === true`
|
* GĐ4 = đúng các HĐ ấy khi `hasSealedCopy === true`
|
||||||
*/
|
*/
|
||||||
export function usePipelineStages() {
|
export function usePipelineStages() {
|
||||||
@ -185,20 +193,72 @@ export function usePipelineStages() {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GĐ2 — Kế hoạch Hợp đồng ────────────────────────────────────────────
|
// ── GĐ2 — Kế hoạch Hợp đồng (8 ngăn theo NHÓM DUYỆT) ───────────────────
|
||||||
const khkkLeaves = linkage.canKhkk
|
const khkkItems = linkage.canKhkk ? peItems.flatMap(p => linkage.khkkByPeId.get(p.id) ?? []) : []
|
||||||
? peItems.flatMap(p => linkage.khkkByPeId.get(p.id) ?? []).map(k => ({
|
const toKhkkLeaf = (k: KhkkListItemDto) => ({
|
||||||
id: k.id,
|
id: k.id,
|
||||||
primary: k.maKeHoach ?? '(chưa cấp mã)',
|
primary: k.maKeHoach ?? '(chưa cấp mã)',
|
||||||
secondary: k.peTenGoiThau,
|
secondary: k.peTenGoiThau,
|
||||||
badgeLabel: KHKK_PHASE_LABELS[k.phase],
|
badgeLabel: KHKK_PHASE_LABELS[k.phase],
|
||||||
badgeClass: KHKK_PHASE_BADGE[k.phase],
|
badgeClass: KHKK_PHASE_BADGE[k.phase],
|
||||||
onOpen: () => navigate(`/khkk/${k.id}`),
|
onOpen: () => navigate(`/khkk/${k.id}`),
|
||||||
}))
|
})
|
||||||
: []
|
const khkkLeaves = khkkItems.map(toKhkkLeaf)
|
||||||
|
|
||||||
|
// [K5 S166 — vá-9] Chia phiếu về ngăn theo `approvalGroup` CỦA CHÍNH PHIẾU.
|
||||||
|
// Phiếu thiếu nhóm / nhóm ngoài miền 1..8 KHÔNG được rơi ra ngoài im lặng —
|
||||||
|
// gom vào ngăn "(chưa phân nhóm)" để nhìn thấy được mà đi sửa dữ liệu.
|
||||||
|
// (`approvalGroup` khai `number` nhưng payload cũ còn trong cache React Query
|
||||||
|
// có thể chưa có field — K2 cũng để `!= null` guard ở 2 trang.)
|
||||||
|
const khkkByGroup = new Map<number, KhkkListItemDto[]>()
|
||||||
|
const khkkUngrouped: KhkkListItemDto[] = []
|
||||||
|
for (const k of khkkItems) {
|
||||||
|
const g = k.approvalGroup
|
||||||
|
if (typeof g === 'number' && (KHKK_APPROVAL_GROUPS as readonly number[]).includes(g)) {
|
||||||
|
const bucket = khkkByGroup.get(g)
|
||||||
|
if (bucket) bucket.push(k)
|
||||||
|
else khkkByGroup.set(g, [k])
|
||||||
|
} else {
|
||||||
|
khkkUngrouped.push(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 🔴 HIỆN ĐỦ 8 NGĂN KỂ CẢ RỖNG — mirror tiền lệ folder giai đoạn chưa có gì
|
||||||
|
// (chip "Sắp"): khung nhóm là thứ người dùng cần thấy để biết chỗ nào trống
|
||||||
|
// và bấm vào lập phiếu, ẩn đi thì họ phải đoán hệ thống có mấy nhóm.
|
||||||
|
const khkkGroups = KHKK_APPROVAL_GROUPS.map(n => ({
|
||||||
|
key: `khkk-g${n}`,
|
||||||
|
label: khkkGroupMenuLabel(n),
|
||||||
|
leaves: (khkkByGroup.get(n) ?? []).map(toKhkkLeaf),
|
||||||
|
// Khớp ĐÚNG URL leaf sidebar nhóm n (K4b) ⇒ bấm ngăn hay bấm menu ra cùng
|
||||||
|
// một trang, và trang đó tự sáng đúng mục bên trái.
|
||||||
|
onOpenAll: () => navigate(`/khkk/list?group=${n}`),
|
||||||
|
}))
|
||||||
|
// Ngăn "chưa phân nhóm" CHỈ hiện khi thật sự có phiếu rơi vào — khác 8 ngăn
|
||||||
|
// trên (8 nhóm là khung cố định của nghiệp vụ, còn đây là chỗ chứa bất thường).
|
||||||
|
if (khkkUngrouped.length > 0) {
|
||||||
|
khkkGroups.push({
|
||||||
|
key: 'khkk-g0',
|
||||||
|
label: '(chưa phân nhóm)',
|
||||||
|
leaves: khkkUngrouped.map(toKhkkLeaf),
|
||||||
|
onOpenAll: () => navigate('/khkk/list'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bất-biến Σ: mỗi phiếu vào ĐÚNG một ngăn ⇒ tổng các ngăn == tổng folder.
|
||||||
|
// Bảo đảm bằng cấu trúc (vòng lặp trên phân hoạch `khkkItems`), dòng dưới chỉ
|
||||||
|
// là chuông báo khi ai đó sửa vòng lặp thành lọc-trùng/lọc-sót. Dev-only:
|
||||||
|
// người dùng cuối không cần thấy, nhưng người sửa code thì cần.
|
||||||
|
const khkkGrouped = khkkGroups.reduce((s, g) => s + g.leaves.length, 0)
|
||||||
|
if (import.meta.env.DEV && khkkGrouped !== khkkLeaves.length) {
|
||||||
|
console.warn(
|
||||||
|
`[pipeline GĐ2] Σ ngăn (${khkkGrouped}) ≠ Σ folder (${khkkLeaves.length}) — có phiếu bị nhân đôi hoặc rơi mất khi chia nhóm.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const stage2: PipelineStage = {
|
const stage2: PipelineStage = {
|
||||||
n: 2,
|
n: 2,
|
||||||
leaves: khkkLeaves,
|
leaves: khkkLeaves,
|
||||||
|
groups: khkkGroups,
|
||||||
locked: !linkage.canKhkk,
|
locked: !linkage.canKhkk,
|
||||||
failed: linkage.khkkFailed,
|
failed: linkage.khkkFailed,
|
||||||
loading: linkage.khkkLoading,
|
loading: linkage.khkkLoading,
|
||||||
@ -208,21 +268,43 @@ export function usePipelineStages() {
|
|||||||
// ── GĐ3 / GĐ4 — HĐ trỏ bởi phiếu (GĐ4 = tập con đã có bản cứng) ─────────
|
// ── 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ì
|
// 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.
|
// biến mất im lặng — nhưng KHÔNG suy được `hasSealedCopy` ⇒ chỉ vào GĐ3.
|
||||||
const seen = new Set<string>()
|
//
|
||||||
|
// [K5 S166 — forward-provision B3-A5] Lấy HĐ của gói bằng một TẬP id gộp từ
|
||||||
|
// NHIỀU nguồn, thay vì đọc thẳng một ô `pe.contractId`. Hôm nay tập ấy chỉ có
|
||||||
|
// một nguồn, nhưng hình dạng đã đúng nên thêm nguồn sau là thêm một dòng.
|
||||||
|
//
|
||||||
|
// 🔴 VÌ SAO CẦN: 1 phiếu có thể đẻ N hợp đồng (liên danh — endpoint
|
||||||
|
// `create-contract` trả `contractIds: string[]`, xem `PeDetailTabs.tsx:2186`),
|
||||||
|
// NHƯNG BE chỉ ghi ngược HĐ ĐẦU vào phiếu:
|
||||||
|
// `CreateContractFromEvaluationFeatures.cs:143-145`
|
||||||
|
// "Idempotency: pe.ContractId = HĐ ĐẦU … N HĐ liên-danh truy qua changelog"
|
||||||
|
// ⇒ HĐ thứ 2 trở đi hiện KHÔNG có đường nào để cây nhìn thấy: `PeListItem`
|
||||||
|
// không có `lines[]`, `ContractListItem` không có `purchaseEvaluationId`,
|
||||||
|
// `Contract.cs` cũng không có khoá ngược. Đây là giới hạn DỮ LIỆU, không
|
||||||
|
// phải chỗ này thiếu logic — đừng "sửa" bằng cách bịa field không tồn tại.
|
||||||
|
// ⇒ KHI BE mở đường (một trong hai: list DTO trả `contractIds[]`, hoặc dòng
|
||||||
|
// phiếu có `contractId` riêng), nối vào ngay dưới đây và xoá ghi chú này.
|
||||||
|
const contractIdsOfPackage = new Set<string>()
|
||||||
|
for (const p of peItems) {
|
||||||
|
if (p.contractId) contractIdsOfPackage.add(p.contractId)
|
||||||
|
// ← chỗ cắm nguồn thứ hai (HĐ 2..N). Ví dụ khi có: `p.contractIds?.forEach(...)`
|
||||||
|
// hoặc `p.lines?.forEach(l => l.contractId && add(l.contractId))`.
|
||||||
|
}
|
||||||
|
|
||||||
const stage3Leaves: PipelineStage['leaves'] = []
|
const stage3Leaves: PipelineStage['leaves'] = []
|
||||||
const stage4Leaves: PipelineStage['leaves'] = []
|
const stage4Leaves: PipelineStage['leaves'] = []
|
||||||
for (const p of peItems) {
|
// `Set` đã khử trùng ⇒ không cần biến `seen` riêng như trước.
|
||||||
if (!p.contractId || seen.has(p.contractId)) continue
|
for (const contractId of contractIdsOfPackage) {
|
||||||
seen.add(p.contractId)
|
|
||||||
const contractId = p.contractId
|
|
||||||
const c = linkage.contractById.get(contractId)
|
const c = linkage.contractById.get(contractId)
|
||||||
|
// Tên gói thầu để làm dòng phụ khi HĐ nằm ngoài cửa sổ index 200.
|
||||||
|
const fallbackName = peItems.find(p => p.contractId === contractId)?.tenGoiThau ?? null
|
||||||
if (linkage.canContract) {
|
if (linkage.canContract) {
|
||||||
stage3Leaves.push({
|
stage3Leaves.push({
|
||||||
id: contractId,
|
id: contractId,
|
||||||
// [F-3 S162] c vắng = HĐ NGOÀI cửa sổ index 200 — đừng dán nhãn
|
// [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).
|
// "(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)',
|
primary: c ? (c.maHopDong ?? '(chưa cấp mã)') : '(ngoài chỉ mục 200 — mở để xem)',
|
||||||
secondary: c?.tenHopDong ?? p.tenGoiThau,
|
secondary: c?.tenHopDong ?? fallbackName,
|
||||||
badgeLabel: c ? ContractPhaseLabel[c.phase] : null,
|
badgeLabel: c ? ContractPhaseLabel[c.phase] : null,
|
||||||
badgeClass: c ? ContractPhaseColor[c.phase] : null,
|
badgeClass: c ? ContractPhaseColor[c.phase] : null,
|
||||||
onOpen: () => navigate(`/contracts/${contractId}`),
|
onOpen: () => navigate(`/contracts/${contractId}`),
|
||||||
|
|||||||
@ -55,9 +55,30 @@ export type PipelineLeaf = {
|
|||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [K5 S166] Ngăn con TRONG một folder giai đoạn (GĐ2 = 8 nhóm duyệt KHKK).
|
||||||
|
* 🔴 `leaves` ở đây là LEAVES THẬT (chính các phiếu con), KHÔNG phải một đường
|
||||||
|
* `content` riêng — nhờ vậy tổng số phiếu của các ngăn luôn bằng số phiếu của
|
||||||
|
* folder cha theo CẤU TRÚC, không phải nhờ ai đó nhớ cộng cho khớp (vá-9).
|
||||||
|
*/
|
||||||
|
export type PipelineGroup = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
leaves: PipelineLeaf[]
|
||||||
|
/** Bấm vào TÊN ngăn ⇒ mở trang danh sách đã lọc sẵn ngăn đó. */
|
||||||
|
onOpenAll?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export type PipelineStage = {
|
export type PipelineStage = {
|
||||||
n: PipelineStageNo
|
n: PipelineStageNo
|
||||||
|
/** Danh sách PHẲNG, là nguồn duy nhất để đếm. `groups` chỉ chia lại chính nó. */
|
||||||
leaves: PipelineLeaf[]
|
leaves: PipelineLeaf[]
|
||||||
|
/**
|
||||||
|
* Có mặt ⇒ thân folder vẽ theo NGĂN thay vì danh sách phẳng. Ngăn RỖNG vẫn
|
||||||
|
* hiện (mirror tiền lệ folder giai đoạn chưa có gì: chip "Sắp") — người dùng
|
||||||
|
* phải thấy đủ khung 8 nhóm để biết cái nào trống, chứ không phải đoán.
|
||||||
|
*/
|
||||||
|
groups?: PipelineGroup[]
|
||||||
/** Không có quyền xem module GĐ này ⇒ folder khoá + "Sắp", KHÔNG gọi API. */
|
/** Không có quyền xem module GĐ này ⇒ folder khoá + "Sắp", KHÔNG gọi API. */
|
||||||
locked?: boolean
|
locked?: boolean
|
||||||
/** Query GĐ này lỗi ⇒ đếm "—", cây KHÔNG vỡ. */
|
/** Query GĐ này lỗi ⇒ đếm "—", cây KHÔNG vỡ. */
|
||||||
@ -85,6 +106,106 @@ function SoonChip() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StageStyle = (typeof STAGE_STYLE)[PipelineStageNo]
|
||||||
|
|
||||||
|
/** Danh sách leaf phẳng — dùng chung cho thân folder LẪN thân từng ngăn (K5). */
|
||||||
|
function LeafList({ leaves, style }: { leaves: PipelineLeaf[]; style: StageStyle }) {
|
||||||
|
return (
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{leaves.map(leaf => (
|
||||||
|
<li key={leaf.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={leaf.onOpen}
|
||||||
|
disabled={!leaf.onOpen}
|
||||||
|
className={cn(
|
||||||
|
'block w-full px-3 py-2 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
|
||||||
|
leaf.onOpen ? 'hover:bg-slate-50' : 'cursor-default',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* Mã to hơn dòng phụ 1 nấc: font-mono 11px NHÌN nhỏ ngang
|
||||||
|
sans 11px nên 2 dòng bẹt như nhau (soi FD2 r1-D). */}
|
||||||
|
<span className={cn('min-w-0 flex-1 truncate font-mono text-[12px] font-medium', style.code)}>
|
||||||
|
{leaf.primary}
|
||||||
|
</span>
|
||||||
|
{leaf.badgeLabel && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
leaf.badgeClass ?? 'bg-slate-100 text-slate-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{leaf.badgeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{leaf.secondary && <div className="truncate text-[11px] text-slate-500">{leaf.secondary}</div>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [K5 S166] Một ngăn trong folder giai đoạn (GĐ2 = 1 nhóm duyệt).
|
||||||
|
* 🔴 Hai thao tác TÁCH BẠCH trên cùng một dòng: bấm vùng dòng = mở/đóng ngăn
|
||||||
|
* (hành vi mặc định của <summary>), bấm ĐÚNG TÊN ngăn = sang trang danh sách đã
|
||||||
|
* lọc sẵn nhóm đó. Nút tên phải chặn nổi bọt, nếu không mỗi lần đi trang lại
|
||||||
|
* kèm một lần đóng/mở ngăn nhấp nháy phía sau.
|
||||||
|
* Mặc định ĐÓNG: 8 ngăn mở sẵn là một bức tường chữ, mà cây này nằm ở cột trái.
|
||||||
|
*/
|
||||||
|
function GroupFolder({ group, style }: { group: PipelineGroup; style: StageStyle }) {
|
||||||
|
const count = group.leaves.length
|
||||||
|
const empty = count === 0
|
||||||
|
return (
|
||||||
|
<details className="group/grp">
|
||||||
|
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60 [&::-webkit-details-marker]:hidden">
|
||||||
|
<ChevronRight
|
||||||
|
aria-hidden
|
||||||
|
className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/grp:rotate-90 motion-reduce:transition-none"
|
||||||
|
/>
|
||||||
|
{group.onOpenAll ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
group.onOpenAll?.()
|
||||||
|
}}
|
||||||
|
title={`Mở danh sách: ${group.label}`}
|
||||||
|
// Ngăn rỗng vẫn bấm sang trang được (để lập phiếu mới cho nhóm đó)
|
||||||
|
// ⇒ vẫn là chữ đang hoạt động: slate-500 giữ đủ tương phản AA.
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 flex-1 truncate rounded text-left text-[11px] font-medium underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/60',
|
||||||
|
empty ? 'text-slate-500' : style.head,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={cn('min-w-0 flex-1 truncate text-[11px] font-medium', empty ? 'text-slate-500' : style.head)}>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{count > 0 ? (
|
||||||
|
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium', style.count)}>{count}</span>
|
||||||
|
) : (
|
||||||
|
<SoonChip />
|
||||||
|
)}
|
||||||
|
</summary>
|
||||||
|
<div className="ml-3 border-l border-slate-200">
|
||||||
|
{empty ? (
|
||||||
|
<div className="px-3 py-1.5 text-[11px] italic text-slate-400">Chưa có phiếu ở nhóm này.</div>
|
||||||
|
) : (
|
||||||
|
<LeafList leaves={group.leaves} style={style} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentStage: PipelineStageNo }) {
|
function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentStage: PipelineStageNo }) {
|
||||||
// Folder của TRANG hiện tại mở sẵn; 3 folder kia đóng. Vẫn collapse/expand được.
|
// Folder của TRANG hiện tại mở sẵn; 3 folder kia đóng. Vẫn collapse/expand được.
|
||||||
const [open, setOpen] = useState(stage.n === currentStage)
|
const [open, setOpen] = useState(stage.n === currentStage)
|
||||||
@ -163,43 +284,19 @@ function StageFolder({ stage, currentStage }: { stage: PipelineStage; currentSta
|
|||||||
</div>
|
</div>
|
||||||
) : stage.content !== undefined ? (
|
) : stage.content !== undefined ? (
|
||||||
stage.content
|
stage.content
|
||||||
|
) : stage.groups !== undefined ? (
|
||||||
|
// [K5 S166] Có ngăn ⇒ vẽ theo ngăn. Ngăn rỗng KHÔNG bị lọc bỏ ở đây:
|
||||||
|
// việc "hiện đủ 8 nhóm hay không" do bên dựng quyết (xem builder), chỗ
|
||||||
|
// này chỉ vẽ đúng thứ nhận được.
|
||||||
|
<div>
|
||||||
|
{stage.groups.map(g => (
|
||||||
|
<GroupFolder key={g.key} group={g} style={style} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : stage.leaves.length === 0 ? (
|
) : stage.leaves.length === 0 ? (
|
||||||
<div className="px-3 py-2 text-[11px] italic text-slate-400">Chưa có dữ liệu ở giai đoạn này.</div>
|
<div className="px-3 py-2 text-[11px] italic text-slate-400">Chưa có dữ liệu ở giai đoạn này.</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-slate-100">
|
<LeafList leaves={stage.leaves} style={style} />
|
||||||
{stage.leaves.map(leaf => (
|
|
||||||
<li key={leaf.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={leaf.onOpen}
|
|
||||||
disabled={!leaf.onOpen}
|
|
||||||
className={cn(
|
|
||||||
'block w-full px-3 py-2 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/60',
|
|
||||||
leaf.onOpen ? 'hover:bg-slate-50' : 'cursor-default',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{/* Mã to hơn dòng phụ 1 nấc: font-mono 11px NHÌN nhỏ ngang
|
|
||||||
sans 11px nên 2 dòng bẹt như nhau (soi FD2 r1-D). */}
|
|
||||||
<span className={cn('min-w-0 flex-1 truncate font-mono text-[12px] font-medium', style.code)}>
|
|
||||||
{leaf.primary}
|
|
||||||
</span>
|
|
||||||
{leaf.badgeLabel && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
|
||||||
leaf.badgeClass ?? 'bg-slate-100 text-slate-600',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{leaf.badgeLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{leaf.secondary && <div className="truncate text-[11px] text-slate-500">{leaf.secondary}</div>}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
{stage.truncated && !stage.failed && !stage.loading && (
|
{stage.truncated && !stage.failed && !stage.loading && (
|
||||||
<div className="border-t border-slate-100 px-3 py-1 text-[10px] italic text-slate-400">
|
<div className="border-t border-slate-100 px-3 py-1 text-[10px] italic text-slate-400">
|
||||||
|
|||||||
@ -24,7 +24,14 @@ import { api } from '@/lib/api'
|
|||||||
import { usePermission } from '@/hooks/usePermission'
|
import { usePermission } from '@/hooks/usePermission'
|
||||||
import type { PipelineStage } from '@/components/pipeline/PipelineStageFolders'
|
import type { PipelineStage } from '@/components/pipeline/PipelineStageFolders'
|
||||||
import { ContractPhaseColor, ContractPhaseLabel, type ContractListItem } from '@/types/contracts'
|
import { ContractPhaseColor, ContractPhaseLabel, type ContractListItem } from '@/types/contracts'
|
||||||
import { KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, type KhkkListItemDto, type PagedResult } from '@/types/khkk'
|
import {
|
||||||
|
KHKK_APPROVAL_GROUPS,
|
||||||
|
KHKK_PHASE_BADGE,
|
||||||
|
KHKK_PHASE_LABELS,
|
||||||
|
khkkGroupMenuLabel,
|
||||||
|
type KhkkListItemDto,
|
||||||
|
type PagedResult,
|
||||||
|
} from '@/types/khkk'
|
||||||
import type { Paged } from '@/types/master'
|
import type { Paged } from '@/types/master'
|
||||||
import {
|
import {
|
||||||
PeDisplayStatusColor,
|
PeDisplayStatusColor,
|
||||||
@ -155,8 +162,9 @@ export type BuildStagesOptions = {
|
|||||||
* Trả `buildStages(peItems)` — dựng 4 folder cho MỘT gói thầu (1 cặp Dự án ×
|
* 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 đó.
|
* 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Đ1 = chính các phiếu ấy
|
||||||
* GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu
|
* GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu, chia thành 8 NGĂN theo
|
||||||
* GĐ3 = HĐ trỏ bởi `pe.contractId`
|
* nhóm duyệt (+ ngăn "(chưa phân nhóm)" khi có phiếu lạc) — K5
|
||||||
|
* GĐ3 = HĐ trong TẬP id của gói (xem forward-provision B3-A5 ở thân hàm)
|
||||||
* GĐ4 = đúng các HĐ ấy khi `hasSealedCopy === true`
|
* GĐ4 = đúng các HĐ ấy khi `hasSealedCopy === true`
|
||||||
*/
|
*/
|
||||||
export function usePipelineStages() {
|
export function usePipelineStages() {
|
||||||
@ -185,20 +193,72 @@ export function usePipelineStages() {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GĐ2 — Kế hoạch Hợp đồng ────────────────────────────────────────────
|
// ── GĐ2 — Kế hoạch Hợp đồng (8 ngăn theo NHÓM DUYỆT) ───────────────────
|
||||||
const khkkLeaves = linkage.canKhkk
|
const khkkItems = linkage.canKhkk ? peItems.flatMap(p => linkage.khkkByPeId.get(p.id) ?? []) : []
|
||||||
? peItems.flatMap(p => linkage.khkkByPeId.get(p.id) ?? []).map(k => ({
|
const toKhkkLeaf = (k: KhkkListItemDto) => ({
|
||||||
id: k.id,
|
id: k.id,
|
||||||
primary: k.maKeHoach ?? '(chưa cấp mã)',
|
primary: k.maKeHoach ?? '(chưa cấp mã)',
|
||||||
secondary: k.peTenGoiThau,
|
secondary: k.peTenGoiThau,
|
||||||
badgeLabel: KHKK_PHASE_LABELS[k.phase],
|
badgeLabel: KHKK_PHASE_LABELS[k.phase],
|
||||||
badgeClass: KHKK_PHASE_BADGE[k.phase],
|
badgeClass: KHKK_PHASE_BADGE[k.phase],
|
||||||
onOpen: () => navigate(`/khkk/${k.id}`),
|
onOpen: () => navigate(`/khkk/${k.id}`),
|
||||||
}))
|
})
|
||||||
: []
|
const khkkLeaves = khkkItems.map(toKhkkLeaf)
|
||||||
|
|
||||||
|
// [K5 S166 — vá-9] Chia phiếu về ngăn theo `approvalGroup` CỦA CHÍNH PHIẾU.
|
||||||
|
// Phiếu thiếu nhóm / nhóm ngoài miền 1..8 KHÔNG được rơi ra ngoài im lặng —
|
||||||
|
// gom vào ngăn "(chưa phân nhóm)" để nhìn thấy được mà đi sửa dữ liệu.
|
||||||
|
// (`approvalGroup` khai `number` nhưng payload cũ còn trong cache React Query
|
||||||
|
// có thể chưa có field — K2 cũng để `!= null` guard ở 2 trang.)
|
||||||
|
const khkkByGroup = new Map<number, KhkkListItemDto[]>()
|
||||||
|
const khkkUngrouped: KhkkListItemDto[] = []
|
||||||
|
for (const k of khkkItems) {
|
||||||
|
const g = k.approvalGroup
|
||||||
|
if (typeof g === 'number' && (KHKK_APPROVAL_GROUPS as readonly number[]).includes(g)) {
|
||||||
|
const bucket = khkkByGroup.get(g)
|
||||||
|
if (bucket) bucket.push(k)
|
||||||
|
else khkkByGroup.set(g, [k])
|
||||||
|
} else {
|
||||||
|
khkkUngrouped.push(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 🔴 HIỆN ĐỦ 8 NGĂN KỂ CẢ RỖNG — mirror tiền lệ folder giai đoạn chưa có gì
|
||||||
|
// (chip "Sắp"): khung nhóm là thứ người dùng cần thấy để biết chỗ nào trống
|
||||||
|
// và bấm vào lập phiếu, ẩn đi thì họ phải đoán hệ thống có mấy nhóm.
|
||||||
|
const khkkGroups = KHKK_APPROVAL_GROUPS.map(n => ({
|
||||||
|
key: `khkk-g${n}`,
|
||||||
|
label: khkkGroupMenuLabel(n),
|
||||||
|
leaves: (khkkByGroup.get(n) ?? []).map(toKhkkLeaf),
|
||||||
|
// Khớp ĐÚNG URL leaf sidebar nhóm n (K4b) ⇒ bấm ngăn hay bấm menu ra cùng
|
||||||
|
// một trang, và trang đó tự sáng đúng mục bên trái.
|
||||||
|
onOpenAll: () => navigate(`/khkk/list?group=${n}`),
|
||||||
|
}))
|
||||||
|
// Ngăn "chưa phân nhóm" CHỈ hiện khi thật sự có phiếu rơi vào — khác 8 ngăn
|
||||||
|
// trên (8 nhóm là khung cố định của nghiệp vụ, còn đây là chỗ chứa bất thường).
|
||||||
|
if (khkkUngrouped.length > 0) {
|
||||||
|
khkkGroups.push({
|
||||||
|
key: 'khkk-g0',
|
||||||
|
label: '(chưa phân nhóm)',
|
||||||
|
leaves: khkkUngrouped.map(toKhkkLeaf),
|
||||||
|
onOpenAll: () => navigate('/khkk/list'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bất-biến Σ: mỗi phiếu vào ĐÚNG một ngăn ⇒ tổng các ngăn == tổng folder.
|
||||||
|
// Bảo đảm bằng cấu trúc (vòng lặp trên phân hoạch `khkkItems`), dòng dưới chỉ
|
||||||
|
// là chuông báo khi ai đó sửa vòng lặp thành lọc-trùng/lọc-sót. Dev-only:
|
||||||
|
// người dùng cuối không cần thấy, nhưng người sửa code thì cần.
|
||||||
|
const khkkGrouped = khkkGroups.reduce((s, g) => s + g.leaves.length, 0)
|
||||||
|
if (import.meta.env.DEV && khkkGrouped !== khkkLeaves.length) {
|
||||||
|
console.warn(
|
||||||
|
`[pipeline GĐ2] Σ ngăn (${khkkGrouped}) ≠ Σ folder (${khkkLeaves.length}) — có phiếu bị nhân đôi hoặc rơi mất khi chia nhóm.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const stage2: PipelineStage = {
|
const stage2: PipelineStage = {
|
||||||
n: 2,
|
n: 2,
|
||||||
leaves: khkkLeaves,
|
leaves: khkkLeaves,
|
||||||
|
groups: khkkGroups,
|
||||||
locked: !linkage.canKhkk,
|
locked: !linkage.canKhkk,
|
||||||
failed: linkage.khkkFailed,
|
failed: linkage.khkkFailed,
|
||||||
loading: linkage.khkkLoading,
|
loading: linkage.khkkLoading,
|
||||||
@ -208,21 +268,43 @@ export function usePipelineStages() {
|
|||||||
// ── GĐ3 / GĐ4 — HĐ trỏ bởi phiếu (GĐ4 = tập con đã có bản cứng) ─────────
|
// ── 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ì
|
// 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.
|
// biến mất im lặng — nhưng KHÔNG suy được `hasSealedCopy` ⇒ chỉ vào GĐ3.
|
||||||
const seen = new Set<string>()
|
//
|
||||||
|
// [K5 S166 — forward-provision B3-A5] Lấy HĐ của gói bằng một TẬP id gộp từ
|
||||||
|
// NHIỀU nguồn, thay vì đọc thẳng một ô `pe.contractId`. Hôm nay tập ấy chỉ có
|
||||||
|
// một nguồn, nhưng hình dạng đã đúng nên thêm nguồn sau là thêm một dòng.
|
||||||
|
//
|
||||||
|
// 🔴 VÌ SAO CẦN: 1 phiếu có thể đẻ N hợp đồng (liên danh — endpoint
|
||||||
|
// `create-contract` trả `contractIds: string[]`, xem `PeDetailTabs.tsx:2186`),
|
||||||
|
// NHƯNG BE chỉ ghi ngược HĐ ĐẦU vào phiếu:
|
||||||
|
// `CreateContractFromEvaluationFeatures.cs:143-145`
|
||||||
|
// "Idempotency: pe.ContractId = HĐ ĐẦU … N HĐ liên-danh truy qua changelog"
|
||||||
|
// ⇒ HĐ thứ 2 trở đi hiện KHÔNG có đường nào để cây nhìn thấy: `PeListItem`
|
||||||
|
// không có `lines[]`, `ContractListItem` không có `purchaseEvaluationId`,
|
||||||
|
// `Contract.cs` cũng không có khoá ngược. Đây là giới hạn DỮ LIỆU, không
|
||||||
|
// phải chỗ này thiếu logic — đừng "sửa" bằng cách bịa field không tồn tại.
|
||||||
|
// ⇒ KHI BE mở đường (một trong hai: list DTO trả `contractIds[]`, hoặc dòng
|
||||||
|
// phiếu có `contractId` riêng), nối vào ngay dưới đây và xoá ghi chú này.
|
||||||
|
const contractIdsOfPackage = new Set<string>()
|
||||||
|
for (const p of peItems) {
|
||||||
|
if (p.contractId) contractIdsOfPackage.add(p.contractId)
|
||||||
|
// ← chỗ cắm nguồn thứ hai (HĐ 2..N). Ví dụ khi có: `p.contractIds?.forEach(...)`
|
||||||
|
// hoặc `p.lines?.forEach(l => l.contractId && add(l.contractId))`.
|
||||||
|
}
|
||||||
|
|
||||||
const stage3Leaves: PipelineStage['leaves'] = []
|
const stage3Leaves: PipelineStage['leaves'] = []
|
||||||
const stage4Leaves: PipelineStage['leaves'] = []
|
const stage4Leaves: PipelineStage['leaves'] = []
|
||||||
for (const p of peItems) {
|
// `Set` đã khử trùng ⇒ không cần biến `seen` riêng như trước.
|
||||||
if (!p.contractId || seen.has(p.contractId)) continue
|
for (const contractId of contractIdsOfPackage) {
|
||||||
seen.add(p.contractId)
|
|
||||||
const contractId = p.contractId
|
|
||||||
const c = linkage.contractById.get(contractId)
|
const c = linkage.contractById.get(contractId)
|
||||||
|
// Tên gói thầu để làm dòng phụ khi HĐ nằm ngoài cửa sổ index 200.
|
||||||
|
const fallbackName = peItems.find(p => p.contractId === contractId)?.tenGoiThau ?? null
|
||||||
if (linkage.canContract) {
|
if (linkage.canContract) {
|
||||||
stage3Leaves.push({
|
stage3Leaves.push({
|
||||||
id: contractId,
|
id: contractId,
|
||||||
// [F-3 S162] c vắng = HĐ NGOÀI cửa sổ index 200 — đừng dán nhãn
|
// [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).
|
// "(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)',
|
primary: c ? (c.maHopDong ?? '(chưa cấp mã)') : '(ngoài chỉ mục 200 — mở để xem)',
|
||||||
secondary: c?.tenHopDong ?? p.tenGoiThau,
|
secondary: c?.tenHopDong ?? fallbackName,
|
||||||
badgeLabel: c ? ContractPhaseLabel[c.phase] : null,
|
badgeLabel: c ? ContractPhaseLabel[c.phase] : null,
|
||||||
badgeClass: c ? ContractPhaseColor[c.phase] : null,
|
badgeClass: c ? ContractPhaseColor[c.phase] : null,
|
||||||
onOpen: () => navigate(`/contracts/${contractId}`),
|
onOpen: () => navigate(`/contracts/${contractId}`),
|
||||||
|
|||||||
Reference in New Issue
Block a user