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>
282 lines
14 KiB
TypeScript
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ý 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 (có thể bạn chưa có 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>
|
|
)
|
|
}
|