Files
solution-erp/fe-user/src/pages/khkk/KhkkListPage.tsx
pqhuy1987 92d8215a62 [CLAUDE] PurchaseEvaluation: YC-028 sự cố prod A/059 — FE tách "404 thật ⟂ lỗi hệ thống" 5 site ×2 app + guard disk/orphan vào runbook+cicd-monitor
Sự cố 2 tầng (đã giải trọn trong phiên, chi tiết runs/2026-08-10-S189-bookstart-open/run.md + WAL):
- T1: ổ C VPS 0 byte (gitea repo-archive 16,49GB + npm-cache SYSTEM 12,76GB) → SQL Error -2
  → "ko duyệt được" rồi FE vẽ mọi lỗi thành "Không tìm thấy phiếu" (#44). Dọn ~30GB cache
  + AUTO_CLOSE OFF ×3 DB + gỡ nén NTFS 4 file template (#88 tái phát).
- T2: 3 sshd mồ côi (lệnh đo ssh bị cắt output) bão hòa 3/3 core → mọi GET detail 5-47s,
  A/059 (5 NCC) vượt trần hủy ~30s của FE. Kill orphan → A/059 47s→0,38s · phiếu bé 0,17s.

Code (5 site cùng lớp, SHA-mirror 4 cặp MATCH, build ×2 xanh):
- lib/apiError.ts +isNotFound(err) — chỉ 404 mới được nói "không tìm thấy"
- PurchaseEvaluationsListPage (inline + overlay + fullpage) + KhkkListPage + KhkkCreatePage:
  detail.isError && !404 → "Hệ thống đang chậm hoặc lỗi khi tải phiếu — thử lại (refetch)"

Ops-guard (YC-028 (4)): cicd-monitor persona +bước 1b server-health (fsutil free <5GB = FAIL,
CPU-delta tìm orphan) · iis-deploy-runbook +§"Disk-free + orphan guard" + sửa path log Serilog
sai (Logs\log-*.txt → logs\solution-erp-*.log, đo thật). (6) đóng: log 10/08 mất do đợt "giải
phóng dung lượng" hôm qua (retention 30 file config đúng). (3B) cron archive_cleanup Gitea +
decompress Vietreport_Master = chờ ngoài giờ (restart service dùng chung VIETREPORT).

Sổ: +YC-028 (nguyên văn + 2 tầng đã xử). Squash wal: trailing theo §5.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:25:51 +07:00

282 lines
14 KiB
TypeScript

// [S171] HOST 3-PANEL của màn "Kế hoạch ký kết HĐ" (GĐ2) — đưa GĐ2 về CÙNG bố-cục với
// Duyệt NCC (GĐ1, `pages/pe/PurchaseEvaluationsListPage.tsx:381,397`) và Hợp đồng
// (GĐ3, `pages/contracts/ContractsListPage.tsx:106,125`). Trước S171 trang này là
// `space-y-5` cuộn-body 2 panel ⇒ mắt-xích lệch DUY NHẤT trong 4 giai-đoạn.
// File MIRROR SHA256 identical với fe-admin counterpart.
//
// Khuôn code lấy từ `ContractsListPage.tsx` (255 dòng, gọn) — CỐ Ý KHÔNG chép
// `PurchaseEvaluationsListPage.tsx` (807 dòng: kéo theo `deleted`/`pendingMe`/`awId`/overlay).
// Số đo panel lấy của PE (400px) vì hiến-pháp owner là "giống nhất với Duyệt NCC".
//
// URL vào từ menu (Layout.tsx — staticMap nhóm 1, regex `Khkk_G{n}_*` nhóm 2..8):
// Khkk_List / Khkk_G{n}_List → /khkk/list?group={n}
// Khkk_Pending / Khkk_G{n}_Pending → /khkk/list?group={n}&filter=ChoDuyet
// Khkk_Approved / Khkk_G{n}_Approved → /khkk/list?group={n}&filter=DaDuyet
// Khkk_Deleted / Khkk_G{n}_Deleted → /khkk/list?group={n}&view=deleted
//
// 🔴 [M-4] KHOÁ URL ĐƯỢC PHÉP GHI = `id` (∈ `TRANSIENT_QUERY_KEYS`, `Layout.tsx:350`).
// TUYỆT ĐỐI KHÔNG ghi `filter` / `view` / `group` vào `setSearchParams`: đứng ở leaf
// `?group=1` mà ghi thêm `filter=DaDuyet` là URL khớp CHÍNH XÁC leaf "Đã duyệt" của nhóm ⇒
// sidebar sáng nhầm mục — đúng bug UAT S155. Bộ lọc người dùng bấm sống ở LỚP OVERRIDE
// (state) như bản cũ, không ghi ngược URL.
//
// 🔴 [K4b S166 · R-5] 8 nhóm dùng CHUNG một route ⇒ mọi thứ lấy từ URL phải ĐỌC LẠI MỖI
// RENDER. Khối reset dưới đây là SET-STATE-TRONG-RENDER **CÓ CHỦ ĐÍCH** (khuôn React
// "reset state khi input đổi") — ai refactor thành `useEffect` sẽ tái-hiện vá-5 (đi từ
// leaf nhóm 2 sang leaf nhóm 5 phải F5 mới đúng). ĐỪNG ĐỘNG.
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { FileCheck, X } from 'lucide-react'
import { EmptyState } from '@/components/EmptyState'
import { KhkkListPanel } from '@/components/khkk/KhkkListPanel'
import { KhkkDetailContent } from '@/components/khkk/KhkkDetailContent'
import { KhkkWorkflowPanel } from '@/components/khkk/KhkkWorkflowPanel'
// [S175 · O6] Xem phiếu Duyệt NCC tại chỗ — dùng LẠI đúng renderer của màn PE thay vì dựng
// bản rút gọn thứ hai (2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia).
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
import { type PeDetailBundle } from '@/types/purchaseEvaluation'
import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { isNotFound } from '@/lib/apiError'
import {
KhkkPhase,
type KhkkDetailDto,
type KhkkPhaseValue,
} from '@/types/khkk'
// Tên phase trong query `?filter=` → int (URL đọc được, không dính số ma thuật).
const FILTER_TO_PHASE: Record<string, KhkkPhaseValue> = {
DangSoanThao: KhkkPhase.DangSoanThao,
ChoDuyet: KhkkPhase.ChoDuyet,
DaDuyet: KhkkPhase.DaDuyet,
TraLai: KhkkPhase.TraLai,
TuChoi: KhkkPhase.TuChoi,
}
export function KhkkListPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const { user } = useAuth()
// ---- 3 trục DANH TÍNH đọc thẳng từ URL (chỉ ĐỌC — không bao giờ ghi lại, xem M-4) ----
const urlFilter = searchParams.get('filter')
const urlPhase: KhkkPhaseValue | null = urlFilter ? (FILTER_TO_PHASE[urlFilter] ?? null) : null
const deletedView = searchParams.get('view') === 'deleted'
const groupParam = searchParams.get('group')
const urlGroup = groupParam !== null && /^[1-8]$/.test(groupParam) ? Number(groupParam) : null
const selectedId = searchParams.get('id')
// ---- Lớp override do NGƯỜI DÙNG bấm, đè lên bộ lọc của URL ----
// 🔴 3 trạng thái, KHÔNG gộp được: `undefined` = chưa chạm ⇒ nghe theo URL; `null` = user
// chủ động chọn "Tất cả"; số = user chọn một giá trị. Gộp `undefined` với `null` thì chọn
// "Tất cả" sẽ bị URL kéo ngược về bộ lọc cũ.
const [chipPhase, setChipPhase] = useState<KhkkPhaseValue | null | undefined>(undefined)
const [groupOverride, setGroupOverride] = useState<number | null | undefined>(undefined)
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
// Đổi leaf ⇒ trả bộ lọc về cho URL và về trang 1. `search` CỐ Ý giữ lại: đang tìm một mã
// rồi đổi nhóm để dò tiếp là thao tác có thật.
const navKey = `${urlGroup ?? ''}|${urlFilter ?? ''}|${deletedView ? 'del' : ''}`
const [lastNavKey, setLastNavKey] = useState(navKey)
if (navKey !== lastNavKey) {
setLastNavKey(navKey)
setChipPhase(undefined)
setGroupOverride(undefined)
setPage(1)
}
const phase: KhkkPhaseValue | null = chipPhase !== undefined ? chipPhase : urlPhase
const group: number | null = groupOverride !== undefined ? groupOverride : urlGroup
const detail = useQuery({
queryKey: ['khkk-detail', selectedId],
queryFn: async () => (await api.get<KhkkDetailDto>(`/contract-signing-plans/${selectedId}`)).data,
enabled: !!selectedId,
})
// [S175 · O6] Phiếu Duyệt NCC đang XEM TẠI CHỖ. Anh chốt: bấm card PE trên cây thì
// GIỮ NGUYÊN màn KHKK, chỉ xem, KHÔNG re-direct về phiếu gốc.
// 🔴 `pe` và `id` LOẠI TRỪ NHAU: panel 2 chỉ vẽ được một thứ, để cả hai thì lần chọn sau
// không xoá được lần trước và người dùng kẹt ở phiếu cũ.
const selectedPeId = searchParams.get('pe')
const peDetail = useQuery({
queryKey: ['pe-detail', selectedPeId],
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${selectedPeId}`)).data,
enabled: !!selectedPeId,
})
function setId(id: string | null) {
const next = new URLSearchParams(searchParams)
if (id == null) next.delete('id')
else next.set('id', id)
next.delete('pe')
setSearchParams(next)
}
function setPeId(id: string | null) {
const next = new URLSearchParams(searchParams)
if (id == null) next.delete('pe')
else next.set('pe', id)
next.delete('id')
setSearchParams(next)
}
function selectPlan(id: string) {
// `<lg` không có 3 panel ⇒ mở trang chi tiết đầy đủ (route `/khkk/:id` GIỮ NGUYÊN).
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
setId(id)
} else {
navigate(group !== null ? `/khkk/${id}?group=${group}` : `/khkk/${id}`)
}
}
function selectPe(id: string) {
// Cùng luật breakpoint với `selectPlan`: `<lg` KHÔNG có panel 2 nên xem tại chỗ là bất
// khả — rơi về đúng đường cũ (trang Duyệt NCC + overlay) thay vì bấm mà không thấy gì.
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
setPeId(id)
} else {
navigate(`/purchase-evaluations?id=${id}&expand=1`)
}
}
const plan = detail.data
// [P2-7] `readOnly` do HOST quyết — KHÔNG cần dựng workspace riêng để có bản chỉ-xem.
const readOnly =
!plan
|| !(plan.phase === KhkkPhase.DangSoanThao || plan.phase === KhkkPhase.TraLai)
|| (!!user?.id && plan.drafterUserId !== user.id)
const headerTitle = deletedView
? (group !== null ? `Kế hoạch ký kết HĐ — N${group} · đã xóa` : 'Kế hoạch ký kết HĐ — đã xóa')
: group !== null ? `Kế hoạch ký kết HĐ — N${group}` : 'Kế hoạch ký kết HĐ'
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
<div className="flex min-w-0 items-center gap-2">
<FileCheck className="h-5 w-5 text-slate-500" />
<h1 className="truncate text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
{/* 🧊 [S175] Chip "Giai đoạn 2 · <tên nhóm dài>" ĐÃ GỠ — anh: "Bỏ mấy dòng này đi".
Tên nhóm đã có trong `headerTitle` ngay bên trái (dạng ngắn "— N2"), và dải 4 giai
đoạn ở panel giữa đã chỉ rõ đang đứng ở GĐ2 ⇒ chip này là lần kể thứ ba. */}
</div>
{/* [S175 - anh: "Bo het di"] Nut "+ Lap ke hoach" o header DA GO. Duong lap ke
hoach di tu man Thao tac (bam phieu PE o cot trai = nap thang vao form tao) —
do moi la duong dung nghiep-vu; nut o day de nguoi dung mo form TRONG khong
gan PE nao. */}
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
{/* Panel 1 — cây toàn trình (owner QĐ-1) + 4 mitigation, xem KhkkListPanel.tsx */}
<KhkkListPanel
selectedId={selectedId}
onSelect={selectPlan}
search={search}
onSearchChange={(v) => { setSearch(v); setPage(1) }}
phase={phase}
onPhaseChange={(v) => { setChipPhase(v); setPage(1) }}
group={group}
deletedView={deletedView}
page={page}
onPageChange={setPage}
selectedFinalize={
plan
? { endedByLevelFinalize: plan.endedByLevelFinalize, finalizeStepName: plan.finalizeStepName }
: undefined
}
onOpenPe={selectPe}
/>
{/* Panel 2 — nội dung phiếu. [F-1] EmptyState là trạng-thái ĐẦU TIÊN người dùng thấy;
thiếu nó thì host 3-panel mở ra là một mảng trắng ở giữa. */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && !selectedPeId && (
<EmptyState
icon={FileCheck}
title={deletedView ? 'Phiếu đã xóa — chỉ xem danh sách' : 'Chọn phiếu ở danh sách'}
description={deletedView
? 'Mục này chỉ liệt kê phiếu đã xóa để đối chiếu. Không mở chi tiết, không thao tác.'
: 'Nội dung kế hoạch + dòng giá NCC + căn cứ hồ sơ + ý kiến cấp duyệt sẽ hiển thị ở đây.'}
/>
)}
{/* [S175 · O6] Xem phiếu Duyệt NCC TẠI CHỖ — `readOnly` + `mode="workspace"` để
chắc chắn không có đường sửa/duyệt nào lọt ra ở màn KHKK (anh chốt "chỉ view").
Băng chỉ-đường ở trên vì panel 2 lúc này KHÔNG còn nói về kế hoạch ký kết nữa —
không nói rõ thì người dùng tưởng mình đang xem KHKK. */}
{selectedPeId && (
<div className="mb-4 flex flex-wrap items-center justify-between gap-2 rounded-md border border-brand-200 bg-brand-50 px-3 py-2 text-[12px] text-brand-800">
<span>Đang xem <strong>phiếu Duyệt NCC</strong> (giai đoạn 1) chỉ xem, không thao tác.</span>
<button
type="button"
onClick={() => setPeId(null)}
className="shrink-0 rounded border border-brand-300 bg-white px-2 py-0.5 font-semibold text-brand-700 transition hover:bg-brand-100"
>
Về kế hoạch kết
</button>
</div>
)}
{selectedPeId && peDetail.isLoading && <div className="text-sm text-slate-500">Đang tải phiếu Duyệt NCC</div>}
{selectedPeId && peDetail.isError && (
<div className="text-sm text-red-600">Không tải đưc phiếu Duyệt NCC ( thể bạn chưa quyền xem).</div>
)}
{selectedPeId && peDetail.data && (
<PeDetailTabs
evaluation={peDetail.data}
readOnly
mode="workspace"
onBack={() => setPeId(null)}
onDelete={() => setPeId(null)}
/>
)}
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải</div>}
{/* [S189 · YC-028(5)] 404 thật ⟂ lỗi hệ thống — mirror PurchaseEvaluationsListPage. */}
{selectedId && !detail.isLoading && !plan && (
detail.isError && !isNotFound(detail.error) ? (
<div className="text-sm text-red-600">
Hệ thống đang chậm hoặc lỗi khi tải phiếu {' '}
<button type="button" className="font-medium underline" onClick={() => detail.refetch()}>thử lại</button>.
</div>
) : (
<div className="text-sm text-red-600">Không tìm thấy phiếu.</div>
)
)}
{selectedId && plan && (
<KhkkDetailContent plan={plan} readOnly={readOnly} onChanged={() => detail.refetch()} onDeleted={() => setId(null)} />
)}
</main>
{/* Panel 3 — quy trình duyệt + lịch sử.
🔴 OWNER CHỐT (QĐ-2): chưa chọn phiếu ⇒ CHỈ hộp dashed y hệt Duyệt NCC
(`PurchaseEvaluationsListPage.tsx:669-676`). KHÔNG nhét `PipelineTreePanel` vào
đây — cây đã sống ở panel 1 theo QĐ-1, nhét lần hai là trùng. Khác GĐ3 ở điểm
này là CÓ CHỦ ĐÍCH. */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
{!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500">
<X className="mx-auto mb-2 h-5 w-5" />
{/* [S175 · O6] Đang xem PE ⇒ NÓI RÕ quy trình duyệt của nó nằm ở màn khác,
thay vì để nguyên câu "sẽ hiện khi chọn phiếu" (sai: phiếu ĐÃ chọn rồi). */}
{selectedPeId
? 'Đang xem phiếu Duyệt NCC — quy trình duyệt của phiếu này nằm ở màn Duyệt NCC.'
: deletedView
? 'Phiếu đã xóa không còn quy trình duyệt.'
: 'Quy trình duyệt sẽ hiện khi chọn phiếu.'}
</div>
)}
{selectedId && plan && (
<KhkkWorkflowPanel
plan={plan}
onChanged={() => detail.refetch()}
onDeleted={() => setId(null)}
/>
)}
</aside>
</div>
</div>
)
}