[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:
237
fe-admin/src/components/pipeline/PipelineStageFolders.tsx
Normal file
237
fe-admin/src/components/pipeline/PipelineStageFolders.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
// [S162 — 2026-07-30] Cây 4 folder GIAI ĐOẠN, đặt DƯỚI TỪNG GÓI THẦU.
|
||||
// Owner 30-07 (screenshot khoanh đỏ dải 4 GĐ trỏ xuống cây trái trang Duyệt NCC):
|
||||
// "Tạo folder: Duyệt NCC / Kế hoạch Hợp đồng / Duyệt Hợp đồng / Hợp đồng cứng",
|
||||
// AskUser chốt (1) folder nằm DƯỚI TỪNG GÓI THẦU · (2) áp CẢ các trang GĐ khác.
|
||||
//
|
||||
// 🔴 Nhãn folder VERBATIM lời owner — GĐ2 CỐ Ý khác nhãn dải strip
|
||||
// (`components/pe/PePipelineStrip.tsx` = "Kế hoạch ký kết HĐ"): lời-mới-nhất-thắng.
|
||||
// KHÔNG sửa strip, KHÔNG sửa nhãn menu.
|
||||
//
|
||||
// File MIRROR SHA256 identical với fe-admin counterpart.
|
||||
//
|
||||
// Component này THUẦN TRÌNH BÀY: nhận `stages` đã dựng sẵn (xem
|
||||
// `hooks/usePipelineStages.ts`) rồi vẽ. GĐ1 trên trang Duyệt NCC truyền `content`
|
||||
// = đúng khối leaf phiếu CŨ ⇒ mọi behavior (chọn phiếu / chip GẤP / nút mở rộng)
|
||||
// giữ nguyên, chỉ được RE-PARENT vào trong folder.
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { ChevronRight, ClipboardCheck, FileCheck, FileSignature, Lock, Stamp } from 'lucide-react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export type PipelineStageNo = 1 | 2 | 3 | 4
|
||||
|
||||
export const PIPELINE_STAGE_LABELS: Record<PipelineStageNo, string> = {
|
||||
1: 'Duyệt NCC',
|
||||
2: 'Kế hoạch Hợp đồng',
|
||||
3: 'Duyệt Hợp đồng',
|
||||
4: 'Hợp đồng cứng',
|
||||
}
|
||||
|
||||
const STAGE_ICON: Record<PipelineStageNo, typeof ClipboardCheck> = {
|
||||
1: ClipboardCheck,
|
||||
2: FileCheck,
|
||||
3: FileSignature,
|
||||
4: Stamp,
|
||||
}
|
||||
|
||||
// Pattern 14 (Tailwind JIT): class dạng LITERAL đầy đủ, không nội suy mảnh.
|
||||
// Palette `@theme` (index.css:29-51) chỉ có stop 50/100/500/600/700 cho teal /
|
||||
// violet / amberx ⇒ head dùng -700 (riêng brand có tới -900).
|
||||
const STAGE_STYLE: Record<PipelineStageNo, { chip: string; head: string; count: string; code: string }> = {
|
||||
1: { chip: 'bg-brand-50 text-brand-600', head: 'text-brand-800', count: 'bg-brand-50 text-brand-700', code: 'text-brand-700' },
|
||||
2: { chip: 'bg-violet-50 text-violet-600', head: 'text-violet-700', count: 'bg-violet-50 text-violet-700', code: 'text-violet-700' },
|
||||
3: { chip: 'bg-amberx-50 text-amberx-600', head: 'text-amberx-700', count: 'bg-amberx-50 text-amberx-700', code: 'text-amberx-700' },
|
||||
4: { chip: 'bg-teal-50 text-teal-600', head: 'text-teal-700', count: 'bg-teal-50 text-teal-700', code: 'text-teal-700' },
|
||||
}
|
||||
|
||||
export type PipelineLeaf = {
|
||||
id: string
|
||||
/** Dòng 1 — mã (font-mono). */
|
||||
primary: string
|
||||
/** Dòng 2 — tên gói thầu / tên HĐ. */
|
||||
secondary?: string | null
|
||||
badgeLabel?: string | null
|
||||
/** Class ĐẦY ĐỦ cho badge (Pattern 14). */
|
||||
badgeClass?: string | null
|
||||
onOpen?: () => void
|
||||
}
|
||||
|
||||
export type PipelineStage = {
|
||||
n: PipelineStageNo
|
||||
leaves: PipelineLeaf[]
|
||||
/** Không có quyền xem module GĐ này ⇒ folder khoá + "Sắp", KHÔNG gọi API. */
|
||||
locked?: boolean
|
||||
/** Query GĐ này lỗi ⇒ đếm "—", cây KHÔNG vỡ. */
|
||||
failed?: boolean
|
||||
loading?: boolean
|
||||
/** Nội dung tự vẽ (GĐ1 trang Duyệt NCC dùng để giữ nguyên leaf phiếu cũ). */
|
||||
content?: ReactNode
|
||||
/** Bắt buộc khi dùng `content` (component không đếm hộ được). */
|
||||
count?: number
|
||||
/** Index nguồn chạm trần cửa sổ 200 (`PagedResult.cs:20`) ⇒ đếm CÓ THỂ thiếu. */
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
function stageCount(stage: PipelineStage): number {
|
||||
return stage.content !== undefined ? (stage.count ?? 0) : stage.leaves.length
|
||||
}
|
||||
|
||||
function SoonChip() {
|
||||
// Token lấy verbatim từ dải strip (PePipelineStrip.tsx:50-52) — uppercase CSS
|
||||
// nên chữ "Sắp" hiển thị "SẮP" đúng như owner gõ.
|
||||
return (
|
||||
<span className="shrink-0 rounded bg-slate-100 px-1 text-[9px] font-semibold uppercase tracking-wide text-slate-400">
|
||||
Sắp
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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.
|
||||
const [open, setOpen] = useState(stage.n === currentStage)
|
||||
const label = PIPELINE_STAGE_LABELS[stage.n]
|
||||
const style = STAGE_STYLE[stage.n]
|
||||
const Icon = STAGE_ICON[stage.n]
|
||||
const count = stageCount(stage)
|
||||
const muted = !!stage.locked || (!stage.failed && count === 0)
|
||||
|
||||
// Khoá quyền: KHÔNG render <details> (không có gì để mở, và cũng không được
|
||||
// phép gọi API để biết) — chỉ 1 dòng khoá + chip "Sắp".
|
||||
// 🔴 Giữ ĐÚNG 3 khoang như dòng folder thường (khoang chevron ⇢ ổ khoá · icon-chip
|
||||
// · nhãn) để 4 dòng thẳng hàng tuyệt đối; bỏ khoang là lệch 26px (soi FD2 r1-D).
|
||||
if (stage.locked) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-3 py-1.5"
|
||||
title="Bạn chưa có quyền xem giai đoạn này"
|
||||
>
|
||||
<Lock aria-hidden className="h-3 w-3 shrink-0 text-slate-400" />
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-slate-100 text-slate-400">
|
||||
<Icon aria-hidden className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-slate-500">{label}</span>
|
||||
<SoonChip />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<details
|
||||
open={open}
|
||||
onToggle={e => setOpen((e.currentTarget as HTMLDetailsElement).open)}
|
||||
className="group/stage"
|
||||
>
|
||||
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 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/stage:rotate-90 motion-reduce:transition-none"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded',
|
||||
muted ? 'bg-slate-100 text-slate-400' : style.chip,
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden className="h-3 w-3" />
|
||||
</span>
|
||||
{/* Folder rỗng VẪN bấm mở được ⇒ nhãn là chữ ĐANG HOẠT ĐỘNG: slate-400
|
||||
(#94a3b8) chỉ 2.8:1 — dưới sàn AA 4.5:1 ⇒ dùng slate-500 (4.8:1).
|
||||
Chip "Sắp"/icon giữ slate-400 theo token dải strip (trạng thái trơ). */}
|
||||
<span className={cn('min-w-0 flex-1 truncate text-[12px] font-medium', muted ? 'text-slate-500' : style.head)}>
|
||||
{label}
|
||||
</span>
|
||||
{stage.failed ? (
|
||||
<span
|
||||
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-400"
|
||||
title="Không tải được dữ liệu giai đoạn này"
|
||||
>
|
||||
—
|
||||
</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">
|
||||
{stage.failed ? (
|
||||
<div className="px-3 py-2 text-[11px] text-slate-400">Không tải được dữ liệu giai đoạn này.</div>
|
||||
) : stage.loading ? (
|
||||
<div className="space-y-1.5 px-3 py-2">
|
||||
<div className="h-3 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : stage.content !== undefined ? (
|
||||
stage.content
|
||||
) : 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>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{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 && (
|
||||
<div className="border-t border-slate-100 px-3 py-1 text-[10px] italic text-slate-400">
|
||||
Chỉ mục tải 200 bản ghi mới nhất — số đếm có thể thiếu.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
export function PipelineStageFolders({
|
||||
stages,
|
||||
currentStage,
|
||||
hideEmpty = false,
|
||||
className,
|
||||
}: {
|
||||
stages: PipelineStage[]
|
||||
currentStage: PipelineStageNo
|
||||
/** Đang lọc (search / trạng thái) ⇒ chỉ hiện folder CÓ nội dung khớp. */
|
||||
hideEmpty?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
// [F-6 S162] Đang lọc vẫn PHẢI thấy folder lỗi/đang tải — ẩn dấu "—" đi là
|
||||
// nói dối trạng thái; chỉ ẩn folder RỖNG THẬT (ok/locked, count 0).
|
||||
const visible = hideEmpty ? stages.filter(s => stageCount(s) > 0 || s.failed || s.loading) : stages
|
||||
if (visible.length === 0) return null
|
||||
return (
|
||||
<div className={className}>
|
||||
{visible.map(s => (
|
||||
<StageFolder key={s.n} stage={s} currentStage={currentStage} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
321
fe-admin/src/components/pipeline/PipelineTreePanel.tsx
Normal file
321
fe-admin/src/components/pipeline/PipelineTreePanel.tsx
Normal file
@ -0,0 +1,321 @@
|
||||
// [S162 — 2026-07-30] Panel cây trái DÙNG CHUNG cho các trang giai đoạn khác
|
||||
// trang Duyệt NCC (owner chốt qua AskUser: "áp cả các trang GĐ khác").
|
||||
// Cấu trúc y hệt cây trang Duyệt NCC — 📅 Năm > 📁 Dự án > 🧱 Hạng mục > 4 folder
|
||||
// giai đoạn — nên người dùng thấy CÙNG một cây ở mọi trang; folder của TRANG
|
||||
// ĐANG ĐỨNG mở sẵn.
|
||||
// File MIRROR SHA256 identical với fe-admin counterpart.
|
||||
//
|
||||
// 🔴 Cây trang Duyệt NCC (`pages/pe/PurchaseEvaluationsListPage.tsx:210-252`) KHÔNG
|
||||
// bị đụng vào: nó có bộ lọc `pendingMe` + `?deleted=1` riêng. Bộ dựng nhóm ở đây
|
||||
// là bản độc lập (duplicate CÓ CHỦ ĐÍCH) để trang đang chạy giữ nguyên hành vi.
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ListTree, Search } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { usePipelineStages } from '@/hooks/usePipelineStages'
|
||||
import { PipelineStageFolders, type PipelineStageNo } from '@/components/pipeline/PipelineStageFolders'
|
||||
import type { Paged } from '@/types/master'
|
||||
import type { PeListItem } from '@/types/purchaseEvaluation'
|
||||
|
||||
const STORAGE_KEY = 'pipeline_tree_expanded_v1'
|
||||
const PE_INDEX_PAGE_SIZE = 200
|
||||
|
||||
type WorkItemNode = { workItemId: string | null; workItemName: string; items: PeListItem[] }
|
||||
type ProjectNode = {
|
||||
projectId: string | null
|
||||
projectName: string
|
||||
projectCode: string
|
||||
workItems: WorkItemNode[]
|
||||
totalCount: number
|
||||
}
|
||||
type YearNode = { year: number; projects: ProjectNode[]; totalCount: number }
|
||||
|
||||
function buildTree(rows: PeListItem[]): YearNode[] {
|
||||
const yearMap = new Map<number, YearNode>()
|
||||
for (const p of rows) {
|
||||
const year = new Date(p.createdAt).getFullYear()
|
||||
let yg = yearMap.get(year)
|
||||
if (!yg) {
|
||||
yg = { year, projects: [], totalCount: 0 }
|
||||
yearMap.set(year, yg)
|
||||
}
|
||||
const projKey = p.projectId ?? '__no_project__'
|
||||
let pg = yg.projects.find(g => (g.projectId ?? '__no_project__') === projKey)
|
||||
if (!pg) {
|
||||
pg = {
|
||||
projectId: p.projectId ?? null,
|
||||
projectName: p.projectName?.trim() || '(Dự án đã xoá)',
|
||||
projectCode: p.projectCode || '',
|
||||
workItems: [],
|
||||
totalCount: 0,
|
||||
}
|
||||
yg.projects.push(pg)
|
||||
}
|
||||
const wiKey = p.workItemId ?? '__no_workitem__'
|
||||
let wg = pg.workItems.find(w => (w.workItemId ?? '__no_workitem__') === wiKey)
|
||||
if (!wg) {
|
||||
wg = {
|
||||
workItemId: p.workItemId ?? null,
|
||||
workItemName: p.workItemName?.trim() || '(Chưa gắn hạng mục)',
|
||||
items: [],
|
||||
}
|
||||
pg.workItems.push(wg)
|
||||
}
|
||||
wg.items.push(p)
|
||||
pg.totalCount++
|
||||
yg.totalCount++
|
||||
}
|
||||
const arr = Array.from(yearMap.values())
|
||||
arr.sort((a, b) => b.year - a.year)
|
||||
for (const yg of arr) {
|
||||
yg.projects.sort((a, b) =>
|
||||
(a.projectCode || a.projectName).localeCompare(b.projectCode || b.projectName, 'vi'),
|
||||
)
|
||||
for (const pg of yg.projects) {
|
||||
// numeric:true — tên hạng mục mở đầu bằng STT không pad ("2 Mat…" < "10 Mat…").
|
||||
pg.workItems.sort((a, b) => a.workItemName.localeCompare(b.workItemName, 'vi', { numeric: true }))
|
||||
for (const wg of pg.workItems) {
|
||||
wg.items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
}
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
export function PipelineTreePanel({
|
||||
currentStage,
|
||||
className,
|
||||
}: {
|
||||
currentStage: PipelineStageNo
|
||||
className?: string
|
||||
}) {
|
||||
const { canPe, buildStages } = usePipelineStages()
|
||||
const [search, setSearch] = useState('')
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
// Cây cần TOÀN BỘ phiếu (không lọc trạng thái) để nhìn được toàn trình.
|
||||
// Key riêng — KHÔNG dùng `pe-list` của trang Duyệt NCC (khác tham số, dùng
|
||||
// chung sẽ đá nhau).
|
||||
const peIndex = useQuery({
|
||||
queryKey: ['pipeline-pe-index'],
|
||||
// [F-4 S162] giữ cả `total` — chạm trần cửa sổ thì khai ngay trên cây.
|
||||
queryFn: async () => {
|
||||
const d = (
|
||||
await api.get<Paged<PeListItem>>('/purchase-evaluations', {
|
||||
params: { page: 1, pageSize: PE_INDEX_PAGE_SIZE },
|
||||
})
|
||||
).data
|
||||
return { items: d.items, total: d.total }
|
||||
},
|
||||
enabled: canPe,
|
||||
staleTime: 60_000,
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
// `null` = người dùng CHƯA từng chỉnh ⇒ dùng nhánh mở mặc định bên dưới.
|
||||
// 🔴 KHÔNG dùng mẹo "set rỗng = mặc định": đóng node cuối lại cho ra set rỗng
|
||||
// ⇒ mặc định bật lại ⇒ node KHÔNG đóng được (soi FD2 r1).
|
||||
const [expandedSet, setExpandedSet] = useState<Set<string> | null>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? new Set(JSON.parse(raw) as string[]) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const term = search.trim().toLowerCase()
|
||||
const rows = useMemo(() => {
|
||||
const all = peIndex.data?.items ?? []
|
||||
if (!term) return all
|
||||
return all.filter(p =>
|
||||
[p.projectCode, p.projectName, p.workItemName, p.maPhieu, p.tenGoiThau]
|
||||
.some(v => (v ?? '').toLowerCase().includes(term)),
|
||||
)
|
||||
}, [peIndex.data, term])
|
||||
|
||||
const years = useMemo(() => buildTree(rows), [rows])
|
||||
const packageCount = years.reduce((n, y) => n + y.projects.reduce((m, p) => m + p.workItems.length, 0), 0)
|
||||
// Đang tìm kiếm ⇒ chỉ hiện folder CÓ nội dung khớp (không đổ 4 folder rỗng).
|
||||
const hideEmpty = term.length > 0
|
||||
|
||||
// Mặc định mở HẾT MỘT NHÁNH (năm mới nhất > dự án đầu > hạng mục đầu) để vừa
|
||||
// vào trang đã THẤY NGAY folder của giai đoạn đang đứng — mở mỗi năm thì folder
|
||||
// nằm sâu 3 lớp, người dùng phải bấm 2 lần mới thấy (soi FD2 r1-B/C).
|
||||
const defaultOpen = useMemo(() => {
|
||||
const keys = new Set<string>()
|
||||
const y = years[0]
|
||||
if (!y) return keys
|
||||
const yearKey = `y${y.year}`
|
||||
keys.add(yearKey)
|
||||
const p = y.projects[0]
|
||||
if (!p) return keys
|
||||
const projKey = `${yearKey}::p${p.projectId ?? '_none_'}`
|
||||
keys.add(projKey)
|
||||
const w = p.workItems[0]
|
||||
if (w) keys.add(`${projKey}::w${w.workItemId ?? '_none_'}`)
|
||||
return keys
|
||||
}, [years])
|
||||
|
||||
const effectiveOpen = expandedSet ?? defaultOpen
|
||||
// Đang lọc ⇒ mở hết để thấy kết quả khớp (cây thu gọn khi tìm = trông như hỏng).
|
||||
const isExpanded = (key: string) => (term ? true : effectiveOpen.has(key))
|
||||
const toggleExpand = (key: string, open: boolean) => {
|
||||
if (term) return // đang lọc thì cây bị ép mở — đừng ghi đè lựa chọn cũ
|
||||
setExpandedSet(prev => {
|
||||
const next = new Set(prev ?? defaultOpen)
|
||||
if (open) next.add(key)
|
||||
else next.delete(key)
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...next])) } catch { /* quota/private mode */ }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn('card-accent flex flex-col self-start overflow-hidden lg:sticky lg:top-4', className)}
|
||||
style={{ ['--accent' as string]: 'var(--color-brand-500)' }}
|
||||
aria-label="Cây toàn trình theo gói thầu"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-slate-200 px-3 py-2.5">
|
||||
<span
|
||||
className="icon-chip h-7! w-7!"
|
||||
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<ListTree className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="label-eyebrow min-w-0 flex-1 truncate">Cây toàn trình</span>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||
{packageCount} gói
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileOpen(o => !o)}
|
||||
aria-expanded={mobileOpen}
|
||||
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 lg:hidden"
|
||||
>
|
||||
{mobileOpen ? 'Ẩn cây' : 'Hiện cây'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={cn('min-h-0 flex-col', mobileOpen ? 'flex' : 'hidden', 'lg:flex')}>
|
||||
<div className="relative border-b border-slate-200 p-2">
|
||||
<Search className="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Lọc dự án / hạng mục / mã phiếu…"
|
||||
aria-label="Lọc cây toàn trình"
|
||||
className="h-8 w-full rounded-md border border-slate-200 bg-white pl-7 pr-2 text-[12px] text-slate-700 placeholder:text-slate-400 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[28rem] overflow-y-auto lg:max-h-[calc(100vh-16rem)]">
|
||||
{!canPe && (
|
||||
<p className="px-3 py-4 text-[12px] text-slate-500">
|
||||
Bạn chưa có quyền xem phiếu Duyệt NCC nên cây toàn trình chưa hiển thị được.
|
||||
</p>
|
||||
)}
|
||||
{canPe && peIndex.isLoading && (
|
||||
<div className="space-y-2 p-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-6 animate-pulse rounded bg-slate-100 motion-reduce:animate-none" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{canPe && peIndex.isError && (
|
||||
<div className="space-y-2 px-3 py-4">
|
||||
<p className="text-[12px] text-slate-500">Không tải được cây toàn trình.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => peIndex.refetch()}
|
||||
className="rounded-md border border-slate-300 px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70"
|
||||
>
|
||||
Thử lại
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{canPe && !peIndex.isLoading && !peIndex.isError && years.length === 0 && (
|
||||
<p className="px-3 py-4 text-[12px] text-slate-500">
|
||||
{term ? 'Không có gói thầu nào khớp từ khoá.' : 'Chưa có phiếu nào để dựng cây.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{canPe && peIndex.data && peIndex.data.total > peIndex.data.items.length && (
|
||||
<p className="border-b border-slate-100 px-3 py-1 text-[10px] italic text-slate-400">
|
||||
Cây tải 200 phiếu mới nhất — gói cũ hơn không hiển thị.
|
||||
</p>
|
||||
)}
|
||||
<div className="divide-y divide-slate-100">
|
||||
{years.map(yg => {
|
||||
const yearKey = `y${yg.year}`
|
||||
return (
|
||||
<details
|
||||
key={yearKey}
|
||||
open={isExpanded(yearKey)}
|
||||
onToggle={e => toggleExpand(yearKey, (e.currentTarget as HTMLDetailsElement).open)}
|
||||
className="group/year"
|
||||
>
|
||||
<summary className="flex cursor-pointer items-center gap-1.5 bg-slate-50 px-3 py-2 hover:bg-slate-100 [&::-webkit-details-marker]:hidden">
|
||||
<svg className="h-3 w-3 shrink-0 text-slate-500 transition-transform group-open/year:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||
<span className="text-base">📅</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-slate-900">Năm {yg.year}</span>
|
||||
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{yg.totalCount}</span>
|
||||
</summary>
|
||||
<div className="ml-3 border-l border-slate-200">
|
||||
{yg.projects.map(pg => {
|
||||
const projKey = `${yearKey}::p${pg.projectId ?? '_none_'}`
|
||||
return (
|
||||
<details
|
||||
key={projKey}
|
||||
open={isExpanded(projKey)}
|
||||
onToggle={e => toggleExpand(projKey, (e.currentTarget as HTMLDetailsElement).open)}
|
||||
className="group/proj"
|
||||
>
|
||||
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
|
||||
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/proj:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||
<span className="text-sm">📁</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-slate-700" title={pg.projectName}>{pg.projectCode || pg.projectName}</span>
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{pg.totalCount}</span>
|
||||
</summary>
|
||||
<div className="ml-3 border-l border-slate-200">
|
||||
{pg.workItems.map(wg => {
|
||||
const wiKey = `${projKey}::w${wg.workItemId ?? '_none_'}`
|
||||
return (
|
||||
<details
|
||||
key={wiKey}
|
||||
open={isExpanded(wiKey)}
|
||||
onToggle={e => toggleExpand(wiKey, (e.currentTarget as HTMLDetailsElement).open)}
|
||||
className="group/wi"
|
||||
>
|
||||
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
|
||||
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/wi:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||
<span className="text-sm">🧱</span>
|
||||
<span className={cn('min-w-0 flex-1 truncate text-[12px]', wg.workItemId ? 'font-medium text-slate-700' : 'italic text-slate-400')}>{wg.workItemName}</span>
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{wg.items.length}</span>
|
||||
</summary>
|
||||
<PipelineStageFolders
|
||||
className="ml-3 border-l border-slate-200"
|
||||
currentStage={currentStage}
|
||||
hideEmpty={hideEmpty}
|
||||
stages={buildStages(wg.items)}
|
||||
/>
|
||||
</details>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
266
fe-admin/src/hooks/usePipelineStages.ts
Normal file
266
fe-admin/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 }
|
||||
}
|
||||
@ -26,6 +26,7 @@ import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
||||
import { api, TOKEN_KEY } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
@ -110,6 +111,12 @@ export function HardCopiesPage() {
|
||||
Có file scan là xem như <span className="font-semibold">xong</span>.
|
||||
</div>
|
||||
|
||||
{/* [S162] Cây toàn trình bám trái (owner chốt AskUser 30-07: folder GĐ áp
|
||||
cả các trang giai đoạn khác) — folder "Hợp đồng cứng" mở sẵn ở đây. */}
|
||||
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)] xl:grid-cols-[21rem_minmax(0,1fr)]">
|
||||
<PipelineTreePanel currentStage={4} />
|
||||
|
||||
<div className="min-w-0 space-y-5">
|
||||
<div
|
||||
className="card-accent flex items-center gap-3 px-4 py-3"
|
||||
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
|
||||
@ -130,6 +137,8 @@ export function HardCopiesPage() {
|
||||
className="card-accent overflow-hidden"
|
||||
style={{ ['--accent' as string]: 'var(--color-teal-500)' }}
|
||||
>
|
||||
{/* [S162] Cột trái ăn ~19rem ⇒ bảng cuộn ngang trong thẻ, không tràn lưới. */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-200 bg-slate-50/70">
|
||||
<tr>
|
||||
@ -217,6 +226,7 @@ export function HardCopiesPage() {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
|
||||
<div className="text-slate-500">
|
||||
@ -238,6 +248,8 @@ export function HardCopiesPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -276,6 +288,7 @@ function HardCopyPanel({ contractId }: { contractId: string }) {
|
||||
setNote('')
|
||||
qc.invalidateQueries({ queryKey: ['contract', contractId] })
|
||||
qc.invalidateQueries({ queryKey: ['hard-copies'] }) // badge cột "Bản cứng" lật
|
||||
qc.invalidateQueries({ queryKey: ['pipeline-contract-index'] }) // [F-7 S162] badge GĐ4 trên cây lật cùng
|
||||
toast.success('Đã lưu bản cứng')
|
||||
},
|
||||
onError: (err) => toast.error(`Tải lên lỗi: ${getErrorMessage(err)}`),
|
||||
|
||||
@ -21,6 +21,7 @@ import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { KpiCard } from '@/components/ui/KpiCard'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { PipelineTreePanel } from '@/components/pipeline/PipelineTreePanel'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
@ -131,8 +132,15 @@ export function KhkkListPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* [S162] Cây toàn trình bám trái + nội dung cũ giữ nguyên bên phải (owner
|
||||
chốt AskUser 30-07: "áp cả các trang GĐ khác"). `minmax(0,1fr)` để bảng
|
||||
7 cột không thổi vỡ lưới ở 1366; `<lg` cây xếp trên và tự thu gọn. */}
|
||||
<div className="grid gap-5 lg:grid-cols-[19rem_minmax(0,1fr)] xl:grid-cols-[21rem_minmax(0,1fr)]">
|
||||
<PipelineTreePanel currentStage={2} />
|
||||
|
||||
<div className="min-w-0 space-y-5">
|
||||
{!deletedView && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||
{phaseCards.map((c) => (
|
||||
<KpiCard
|
||||
key={c.value ?? 'all'}
|
||||
@ -164,6 +172,9 @@ export function KhkkListPage() {
|
||||
</div>
|
||||
|
||||
<div className="card-accent overflow-hidden" style={{ ['--accent' as string]: 'var(--color-brand-500)' }}>
|
||||
{/* [S162] Cột trái ăn ~19rem ⇒ bảng 7 cột cuộn ngang trong thẻ thay vì
|
||||
tràn ra ngoài lưới trên laptop 1366. */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-200 bg-slate-50/70">
|
||||
<tr>
|
||||
@ -244,6 +255,7 @@ export function KhkkListPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-sm">
|
||||
<div className="text-slate-500">
|
||||
@ -260,6 +272,8 @@ export function KhkkListPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -29,6 +29,8 @@ import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
|
||||
import { PeUrgentChips } from '@/components/pe/PeUrgentChips'
|
||||
import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip'
|
||||
import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel'
|
||||
import { PipelineStageFolders } from '@/components/pipeline/PipelineStageFolders'
|
||||
import { usePipelineStages } from '@/hooks/usePipelineStages'
|
||||
|
||||
export function PurchaseEvaluationsListPage() {
|
||||
const qc = useQueryClient()
|
||||
@ -47,6 +49,14 @@ export function PurchaseEvaluationsListPage() {
|
||||
const selectedId = sp.get('id')
|
||||
const isExpand = sp.get('expand') === '1' // S79 — overlay mở rộng
|
||||
|
||||
// [S162] Nguồn 4 folder giai đoạn dưới từng gói thầu (GĐ2 KHKK / GĐ3 HĐ /
|
||||
// GĐ4 HĐ cứng). Hook tự gate quyền TRƯỚC khi fetch — không quyền thì không
|
||||
// gọi API, folder hiện dạng khoá.
|
||||
const { buildStages } = usePipelineStages()
|
||||
// Đang lọc (tìm kiếm / trạng thái / "Chờ tôi" / "Đã xóa") ⇒ chỉ hiện folder
|
||||
// CÓ nội dung khớp, tránh đổ 4 folder rỗng dưới mọi gói thầu.
|
||||
const filterActive = search.trim() !== '' || phase !== '' || pendingMe || deletedView
|
||||
|
||||
// Mig 23 — list quy trình duyệt V2 cho dropdown filter (filter theo type screen)
|
||||
const approvalWorkflows = useQuery({
|
||||
queryKey: ['approval-workflows-v2-filter', typeFilter],
|
||||
@ -507,7 +517,12 @@ export function PurchaseEvaluationsListPage() {
|
||||
<span className={cn('flex-1 truncate text-[12px]', wg.workItemId ? 'font-medium text-slate-700' : 'italic text-slate-400')}>{wg.workItemName}</span>
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{wg.items.length}</span>
|
||||
</summary>
|
||||
<ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200">
|
||||
{/* [S162] 4 folder giai đoạn dưới GÓI THẦU (owner chốt AskUser 30-07).
|
||||
Khối <li> phiếu bên dưới GIỮ NGUYÊN từng byte — kể cả thụt lề — để
|
||||
diff chứng minh không đụng behavior chọn phiếu / chip GẤP / nút
|
||||
"Xem mở rộng"; nó chỉ được RE-PARENT vào folder GĐ1 "Duyệt NCC". */}
|
||||
<PipelineStageFolders className="ml-3 border-l border-slate-200" currentStage={1} hideEmpty={filterActive} stages={buildStages(wg.items, { stage1Content: (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{wg.items.map(p => (
|
||||
<li key={p.id}>
|
||||
{/* S79 — dòng phiếu: vùng bấm chính (chọn → inline) + nút
|
||||
@ -607,6 +622,7 @@ export function PurchaseEvaluationsListPage() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) })} />
|
||||
</details>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user