[CLAUDE] FE-User: YC-029 W2 — hoán đổi cây toàn trình sang trái + Select loại + list phải (mirror cặp pipeline ×2 app)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-12 19:29:15 +07:00
parent 09ebd20eda
commit 7b291014fc
5 changed files with 182 additions and 63 deletions

View File

@ -108,7 +108,10 @@ export function PipelineTreePanel({
// NGUYÊN hành vi cũ (8 ngăn nhóm + navigate) ở trang Duyệt NCC / GĐ3 / GĐ4.
// [S175 · O6] +`onOpenPe`: leaf GĐ1 (phiếu Duyệt NCC) cũng cho host bắt tại chỗ, đối
// xứng với `onOpenKhkk` của leaf GĐ2. Cả 2 đều OPT-IN nên 4 host không truyền giữ nguyên.
stage2Options?: Pick<BuildStagesOptions, 'stage2Content' | 'stage2Count' | 'onOpenKhkk' | 'onOpenPe'>
// [S190 · W2 YC-029] +`onOpenContract` (leaf GĐ3 — khép kín bộ ba GĐ1/GĐ2/GĐ3) và
// +`stage3TypeFilter` (lọc leaf GĐ3 theo loại HĐ, nguồn = Select loại ở host `my-contracts`).
// GIỮ TÊN prop `stage2Options` dù nay mang cả tuỳ-chọn GĐ3: đổi tên = chạm 7 host, ngoài wave.
stage2Options?: Pick<BuildStagesOptions, 'stage2Content' | 'stage2Count' | 'onOpenKhkk' | 'onOpenPe' | 'onOpenContract' | 'stage3TypeFilter'>
// [S168 GĐ3-D gate F-1] Host 3-panel (Panel-3 TỰ CUỘN) truyền class trần riêng cho THÂN
// cây (vd `lg:max-h-none` — panel giữ đúng 1 thanh cuộn; max-h mặc định 100vh16rem là
// hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau).

View File

@ -190,6 +190,22 @@ export type BuildStagesOptions = {
* `navigate('/purchase-evaluations?id=…&expand=1')` ⇒ 5 host còn lại 0 đổi hành vi.
*/
onOpenPe?: (id: string) => void
/**
* [S190 · W2 YC-029] Bộ ba khép kín với `onOpenKhkk` (GĐ2) / `onOpenPe` (GĐ1): leaf GĐ3 (HĐ)
* cho HOST bắt tại chỗ. Màn `my-contracts` sau hoán-đổi đặt cây ở panel-1 TRÁI ⇒ bấm leaf phải
* chọn HĐ vào panel-2 (URL ở lại `/my-contracts`), không rời trang.
* không truyền ⇒ giữ nguyên `navigate('/contracts/:id')` — 6 host còn lại (kể cả fe-admin
* `ContractsListPage.tsx:265`) 0 đổi hành vi.
*/
onOpenContract?: (id: string) => void
/**
* [S190 · W2 YC-029] Lọc leaf GĐ3 theo `ContractType` (1..7) — nguồn là thanh `Select` loại HĐ
* ở đầu panel cây `my-contracts` (Q2 nhánh-đã-chọn).
* không truyền ⇒ giữ nguyên cây đủ MỌI loại HĐ — 6 host còn lại 0 đổi hành vi.
* 🔴 HĐ nằm NGOÀI cửa sổ index 200 KHÔNG suy được loại ⇒ vẫn hiện dù filter bật; lý-do ghi tại
* chỗ dùng (`passStage3Type`).
*/
stage3TypeFilter?: number
}
/**
@ -365,7 +381,13 @@ export function usePipelineStages() {
peItems.find(p => p.contractId === contractId)?.tenGoiThau
?? khkkItems.find(k => khkkPlanContractIds(k).includes(contractId))?.peTenGoiThau
?? null
if (linkage.canContract) {
// [S190 W2] Vế lọc loại HĐ — CHỈ chạy khi host bật `stage3TypeFilter` (opt-in).
// 🔴 QUYẾT ĐỊNH "THÀ THỪA CÒN HƠN THIẾU": `c` vắng = HĐ ngoài cửa sổ index 200 (xem
// `primary` ngay dưới) ⇒ KHÔNG có `c.type` để phân loại. Ẩn nó đi khi filter bật là làm
// HĐ biến mất IM LẶNG — đúng cái bệnh F-3 S162 đã vá bằng nhãn "(ngoài chỉ mục 200)".
// Vậy nên `!c` được cho QUA: người dùng thấy thừa 1 leaf còn hơn mất trắng 1 HĐ.
const passStage3Type = !opts.stage3TypeFilter || !c || c.type === opts.stage3TypeFilter
if (linkage.canContract && passStage3Type) {
stage3Leaves.push({
id: contractId,
// [F-3 S162] c vắng = HĐ NGOÀI cửa sổ index 200 — đừng dán nhãn
@ -374,7 +396,11 @@ export function usePipelineStages() {
secondary: c?.tenHopDong ?? fallbackName,
badgeLabel: c ? ContractPhaseLabel[c.phase] : null,
badgeClass: c ? ContractPhaseColor[c.phase] : null,
onOpen: () => navigate(`/contracts/${contractId}`),
// [S190 W2] Host truyền callback ⇒ chọn tại chỗ; không truyền ⇒ navigate như cũ.
// Ternary y khuôn leaf GĐ1 `:228-230` / GĐ2 `:244` — 3 giai-đoạn cùng một luật.
onOpen: opts.onOpenContract
? () => opts.onOpenContract!(contractId)
: () => navigate(`/contracts/${contractId}`),
})
}
if (linkage.canHardCopy && c?.hasSealedCopy) {

View File

@ -108,7 +108,10 @@ export function PipelineTreePanel({
// NGUYÊN hành vi cũ (8 ngăn nhóm + navigate) ở trang Duyệt NCC / GĐ3 / GĐ4.
// [S175 · O6] +`onOpenPe`: leaf GĐ1 (phiếu Duyệt NCC) cũng cho host bắt tại chỗ, đối
// xứng với `onOpenKhkk` của leaf GĐ2. Cả 2 đều OPT-IN nên 4 host không truyền giữ nguyên.
stage2Options?: Pick<BuildStagesOptions, 'stage2Content' | 'stage2Count' | 'onOpenKhkk' | 'onOpenPe'>
// [S190 · W2 YC-029] +`onOpenContract` (leaf GĐ3 — khép kín bộ ba GĐ1/GĐ2/GĐ3) và
// +`stage3TypeFilter` (lọc leaf GĐ3 theo loại HĐ, nguồn = Select loại ở host `my-contracts`).
// GIỮ TÊN prop `stage2Options` dù nay mang cả tuỳ-chọn GĐ3: đổi tên = chạm 7 host, ngoài wave.
stage2Options?: Pick<BuildStagesOptions, 'stage2Content' | 'stage2Count' | 'onOpenKhkk' | 'onOpenPe' | 'onOpenContract' | 'stage3TypeFilter'>
// [S168 GĐ3-D gate F-1] Host 3-panel (Panel-3 TỰ CUỘN) truyền class trần riêng cho THÂN
// cây (vd `lg:max-h-none` — panel giữ đúng 1 thanh cuộn; max-h mặc định 100vh16rem là
// hiệu-chỉnh cho trang giấy-cuộn, vượt chỗ Panel-3 /inbox ~26px ⇒ 2 cuộn lồng nhau).

View File

@ -190,6 +190,22 @@ export type BuildStagesOptions = {
* `navigate('/purchase-evaluations?id=…&expand=1')` ⇒ 5 host còn lại 0 đổi hành vi.
*/
onOpenPe?: (id: string) => void
/**
* [S190 · W2 YC-029] Bộ ba khép kín với `onOpenKhkk` (GĐ2) / `onOpenPe` (GĐ1): leaf GĐ3 (HĐ)
* cho HOST bắt tại chỗ. Màn `my-contracts` sau hoán-đổi đặt cây ở panel-1 TRÁI ⇒ bấm leaf phải
* chọn HĐ vào panel-2 (URL ở lại `/my-contracts`), không rời trang.
* không truyền ⇒ giữ nguyên `navigate('/contracts/:id')` — 6 host còn lại (kể cả fe-admin
* `ContractsListPage.tsx:265`) 0 đổi hành vi.
*/
onOpenContract?: (id: string) => void
/**
* [S190 · W2 YC-029] Lọc leaf GĐ3 theo `ContractType` (1..7) — nguồn là thanh `Select` loại HĐ
* ở đầu panel cây `my-contracts` (Q2 nhánh-đã-chọn).
* không truyền ⇒ giữ nguyên cây đủ MỌI loại HĐ — 6 host còn lại 0 đổi hành vi.
* 🔴 HĐ nằm NGOÀI cửa sổ index 200 KHÔNG suy được loại ⇒ vẫn hiện dù filter bật; lý-do ghi tại
* chỗ dùng (`passStage3Type`).
*/
stage3TypeFilter?: number
}
/**
@ -365,7 +381,13 @@ export function usePipelineStages() {
peItems.find(p => p.contractId === contractId)?.tenGoiThau
?? khkkItems.find(k => khkkPlanContractIds(k).includes(contractId))?.peTenGoiThau
?? null
if (linkage.canContract) {
// [S190 W2] Vế lọc loại HĐ — CHỈ chạy khi host bật `stage3TypeFilter` (opt-in).
// 🔴 QUYẾT ĐỊNH "THÀ THỪA CÒN HƠN THIẾU": `c` vắng = HĐ ngoài cửa sổ index 200 (xem
// `primary` ngay dưới) ⇒ KHÔNG có `c.type` để phân loại. Ẩn nó đi khi filter bật là làm
// HĐ biến mất IM LẶNG — đúng cái bệnh F-3 S162 đã vá bằng nhãn "(ngoài chỉ mục 200)".
// Vậy nên `!c` được cho QUA: người dùng thấy thừa 1 leaf còn hơn mất trắng 1 HĐ.
const passStage3Type = !opts.stage3TypeFilter || !c || c.type === opts.stage3TypeFilter
if (linkage.canContract && passStage3Type) {
stage3Leaves.push({
id: contractId,
// [F-3 S162] c vắng = HĐ NGOÀI cửa sổ index 200 — đừng dán nhãn
@ -374,7 +396,11 @@ export function usePipelineStages() {
secondary: c?.tenHopDong ?? fallbackName,
badgeLabel: c ? ContractPhaseLabel[c.phase] : null,
badgeClass: c ? ContractPhaseColor[c.phase] : null,
onOpen: () => navigate(`/contracts/${contractId}`),
// [S190 W2] Host truyền callback ⇒ chọn tại chỗ; không truyền ⇒ navigate như cũ.
// Ternary y khuôn leaf GĐ1 `:228-230` / GĐ2 `:244` — 3 giai-đoạn cùng một luật.
onOpen: opts.onOpenContract
? () => opts.onOpenContract!(contractId)
: () => navigate(`/contracts/${contractId}`),
})
}
if (linkage.canHardCopy && c?.hasSealedCopy) {

View File

@ -1,10 +1,21 @@
// 3-panel "Danh sách HĐ" — Panel 1 (list compact, click chọn) | Panel 2
// (detail content) | Panel 3 (workflow + lịch sử duyệt). Selected HĐ giữ qua
// URL `?id=` để bookmarkable + back/forward navigation work.
// 3-panel "Danh sách HĐ" — [S190 · W2 YC-029 Đ1] HOÁN ĐỔI 2 khung theo ảnh anh gửi
// ("Bỏ phần này sang bên này nhé" — mũi tên từ cây PHẢI chỉ sang danh-sách TRÁI):
// Panel 1 TRÁI 400px = CÂY TOÀN TRÌNH (GĐ3) + Select loại HĐ ← trước ở Panel 3
// Panel 2 GIỮA = detail content + quy trình/lịch sử duyệt của HĐ đang chọn
// Panel 3 PHẢI 360px = DANH SÁCH HĐ (search + empty-state) ← trước ở Panel 1
// Selected HĐ vẫn giữ qua URL `?id=` để bookmarkable + back/forward navigation work.
//
// Mobile fallback (< lg): hiển thị Panel 1 list, click row → fullpage
// /contracts/:id (giữ flow cũ, không cố nhồi 3 panel vào màn hình hẹp).
import { useMemo } from 'react'
// 🔴 CHỈ ĐỔI CHỖ, KHÔNG XOÁ KHỐI NÀO (Q1):
// • Danh-sách phải ở lại: cây chỉ dựng từ phiếu Duyệt NCC ⇒ HĐ tạo TRỰC-TIẾP ngoài gói thầu
// KHÔNG bao giờ hiện trên cây, danh-sách là đường DUY NHẤT tới nó.
// • `WorkflowHistoryPanel` trước ở Panel 3 (chỗ nay là danh-sách) ⇒ dời xuống NGAY DƯỚI detail
// ở Panel 2 chứ không bỏ. Delta này plan W2 KHÔNG nêu (plan chỉ nói "panel-2 giữ detail") —
// khai ở đây để người sau khỏi tưởng ai đó lỡ tay đánh rơi.
//
// Mobile (< lg): GIỮ NGUYÊN 100% hiện trạng — chỉ danh-sách hiện, click row → fullpage
// /contracts/:id. Cây là khối DESKTOP-ONLY y như TRƯỚC hoán-đổi (nó vốn nằm trong panel
// `hidden lg:block`), nên đợt này không đổi một pixel nào ở < lg.
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { FileText, Plus, Search } from 'lucide-react'
@ -16,6 +27,7 @@ import { SlaTimer } from '@/components/SlaTimer'
import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import type { Paged } from '@/types/master'
@ -41,10 +53,27 @@ export function MyContractsPage() {
const phaseFilter = searchParams.get('phase') ? Number(searchParams.get('phase')) : null
const showDeleted = searchParams.get('deleted') === '1'
// [S190 W2 · Q2 nhánh-đã-chọn] Select loại HĐ ở đầu panel CÂY. Mặc định = `?type=N` của URL
// (menu loại HĐ đang đứng). 🔴 Đổi Select CHỈ đổi bộ lọc CÂY — KHÔNG navigate, KHÔNG đụng
// danh-sách panel-3 (danh-sách vẫn bám `?type=` của menu, đúng danh-tính leaf W1).
//
// 🔴 SYNC BẰNG PHÉP DẪN XUẤT, KHÔNG BẰNG `useEffect`: override được ĐÓNG DẤU loại nó thuộc về
// (`forType`). URL đổi loại (bấm sang "02. HĐ Giao khoán") ⇒ dấu không khớp nữa ⇒ tự rơi về
// mặc định MỚI, không cần đồng bộ tay. Bản đầu viết `useEffect(() => setTreeType(typeFilter))`
// và ăn ĐÚNG 1 lỗi `react-hooks/set-state-in-effect` (baseline eslint 47 → 48) — dẫn xuất vừa
// hết lỗi vừa hết cả một nhịp render thừa.
const [treeTypeOverride, setTreeTypeOverride] =
useState<{ forType: number | null; value: number | null } | null>(null)
const treeType = treeTypeOverride !== null && treeTypeOverride.forType === typeFilter
? treeTypeOverride.value
: typeFilter
const list = useQuery({
// `typeFilter` ĐÃ nằm trong key từ trước ⇒ đổi sang lọc server KHÔNG cần thêm trục mới;
// cache cũ (payload chưa lọc) tự hết hiệu lực vì mỗi `type` là 1 ô key riêng sẵn rồi.
queryKey: ['my-contracts', typeFilter, phaseFilter, showDeleted],
// [S190 W2] +trục `search`: ô tìm nay đi SERVER-SIDE (xem `params.search`) ⇒ mỗi từ khoá
// là một payload khác nhau, thiếu trục này là ăn cache của từ khoá trước.
queryKey: ['my-contracts', typeFilter, phaseFilter, showDeleted, search],
queryFn: async () =>
(
await api.get<Paged<ContractListItem>>(showDeleted ? '/contracts/deleted' : '/contracts', {
@ -58,6 +87,13 @@ export function MyContractsPage() {
// BE lọc TRƯỚC phân trang (`ContractFeatures.cs:325` trước `Skip/Take:340`).
// `?? undefined` ⇒ axios bỏ hẳn key, giữ nguyên hành vi "không chọn loại".
type: typeFilter ?? undefined,
// [S190 W2] 🔴 ĐỔI HÀNH-VI CÓ CHỦ ĐÍCH: ô tìm chuyển client-side → SERVER-SIDE.
// Param `search` sẵn ở CẢ 2 endpoint (`ContractsController.cs:19` và `:36`).
// Vì sao đổi: lọc client chỉ soi trong 100 dòng đã tải ⇒ user nhiều HĐ gõ đúng mã
// vẫn ra rỗng (cùng bệnh `type` đã vá @W7 S187).
// Diện tìm RỘNG HƠN bản cũ (`ContractFeatures.cs:358-364`): mã · tên HĐ · tên NCC
// · +tên DỰ ÁN (vế client cũ không có) — rộng hơn nên không mất kết quả nào.
search: search.trim() || undefined,
...(!showDeleted && phaseFilter != null ? { phase: phaseFilter } : {}),
},
})
@ -70,30 +106,19 @@ export function MyContractsPage() {
enabled: !!selectedId,
})
const rows = useMemo(() => {
// [W7 S187] Vế `items.filter(c => c.type === typeFilter)` ĐÃ GỠ — BE lọc rồi. Giữ lại
// = lọc 2 lần, và che mất lỗi nếu server trả sai loại (sai sẽ THẦM LẶNG). `search` vẫn
// client-side CÓ CHỦ ĐÍCH: ô tìm không đổi request (payload 100 dòng đã tải trọn).
let items = list.data?.items ?? []
if (search.trim()) {
const q = search.toLowerCase()
items = items.filter(c =>
(c.maHopDong ?? '').toLowerCase().includes(q) ||
(c.tenHopDong ?? '').toLowerCase().includes(q) ||
(c.supplierName ?? '').toLowerCase().includes(q),
)
}
return items
// `typeFilter` gỡ khỏi deps CÙNG LÚC với vế lọc (không còn đọc trong thân hàm).
}, [list.data, search])
// [W7 S187] Vế `items.filter(c => c.type === typeFilter)` ĐÃ GỠ — BE lọc rồi.
// [S190 W2] Vế lọc `search` client-side cũng GỠ NỐT theo lý-do y hệt: giữ lại = lọc 2 lần và
// che mất lỗi nếu server trả sai (sai sẽ THẦM LẶNG). Không còn phép biến đổi nào ⇒ bỏ luôn
// `useMemo` (memo hoá một phép gán là chi phí thừa).
const rows = list.data?.items ?? []
function selectContract(id: string) {
// [S159-đợt5 FLAG-2 reviewer] Màn "Đã xóa" = CHỈ XEM DANH SÁCH (khuôn PE S155
// `disabled={deletedView}`): detail endpoint KHÔNG IgnoreQueryFilters ⇒ HĐ đã xóa
// 404 ⇒ desktop = 2 panel trắng, mobile = navigate sang trang 404. Chặn tại gốc.
if (showDeleted) return
// Desktop ≥ lg: cập nhật URL để render Panel 2/3 cạnh List.
// Mobile: Panel 2/3 hidden → navigate fullpage /contracts/:id (UX khả dụng).
// Desktop ≥ lg: cập nhật URL để render Panel 2 cạnh cây/danh-sách.
// Mobile: Panel 2 hidden → navigate fullpage /contracts/:id (UX khả dụng).
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
const next = new URLSearchParams(searchParams)
next.set('id', id)
@ -149,10 +174,74 @@ export function MyContractsPage() {
)}
</header>
{/* 3-panel grid — flex-1 để fill phần còn lại của viewport */}
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[320px_1fr_360px]">
{/* Panel 1 — List */}
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
{/* 3-panel grid — flex-1 để fill phần còn lại của viewport.
[S190 W2] 320px → 400px cho cột cây (khuôn host KHKK `KhkkListPage.tsx:171`). */}
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
{/* Panel 1 — CÂY TOÀN TRÌNH (đến từ Panel 3 cũ) + Select loại HĐ.
`hidden … lg:flex` = GIỮ NGUYÊN diện hiển thị cũ của cây (nó vốn ở panel
`hidden lg:block`) ⇒ < lg không đổi gì, và bẫy "`hideHeader` nuốt nút Hiện/Ẩn cây
ở mobile" KHÔNG chạm host này vì nút đó chỉ sống ở < lg. */}
<aside className="hidden flex-col overflow-hidden border-r border-slate-200 bg-white lg:flex">
<div className="border-b border-slate-200 p-3">
<Select
value={treeType ?? ''}
aria-label="Lọc loại HĐ trên cây toàn trình"
onChange={e => setTreeTypeOverride({
forType: typeFilter,
value: e.target.value === '' ? null : Number(e.target.value),
})}
>
{/* Ca URL trần (`/my-contracts` không `?type=`) phải có lựa chọn hợp lệ. */}
<option value="">Tất cả loại </option>
{Object.entries(ContractTypeLabel).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</Select>
</div>
{/* [X-3 / SF-26 — bài S168 F-1] Cột này TỰ CUỘN ⇒ thân cây PHẢI bỏ trần `max-h` mặc
định của panel, nếu không là 2 thanh cuộn lồng nhau. */}
<div className="flex-1 overflow-y-auto">
<PipelineTreePanel
currentStage={3}
hideHeader
hideSearch
bodyMaxHClass="lg:max-h-none"
stage2Options={{
// Bấm leaf HĐ ⇒ chọn vào Panel 2 qua `?id=`; URL Ở LẠI `/my-contracts`.
onOpenContract: selectContract,
// `null` ⇒ `undefined` ⇒ hook coi như KHÔNG lọc (đủ mọi loại).
stage3TypeFilter: treeType ?? undefined,
}}
/>
</div>
</aside>
{/* Panel 2 — Detail content + quy trình/lịch sử duyệt */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && (
<EmptyState
icon={FileText}
title="Chọn 1 HĐ ở cây bên trái hoặc danh sách bên phải"
description="Chi tiết HĐ + thông tin / góp ý / file đính kèm sẽ hiển thị ở đây."
/>
)}
{selectedId && detail.isLoading && (
<div className="text-sm text-slate-500">Đang tải </div>
)}
{selectedId && detail.data && (
<div className="space-y-6">
<ContractDetailContent contract={detail.data} onBack={clearSelection} />
{/* [S190 W2] Khối "workflow + lịch sử duyệt" TRƯỚC ở Panel 3; Panel 3 nay là
danh-sách nên nó về đứng ngay dưới detail của chính HĐ đó — KHÔNG xoá. */}
<WorkflowHistoryPanel contract={detail.data} />
</div>
)}
</main>
{/* Panel 3 — DANH SÁCH HĐ (đến từ Panel 1 cũ, nguyên chức năng: search + empty-state +
các lọc `?type=` / `?phase=9` / `?deleted=1` hiện có). Hiện ở MỌI bề rộng y như
trước hoán-đổi ⇒ mobile không mất gì. */}
<aside className="flex flex-col overflow-hidden border-l border-slate-200 bg-white">
<div className="border-b border-slate-200 p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
@ -214,34 +303,6 @@ export function MyContractsPage() {
</ul>
</div>
</aside>
{/* Panel 2 — Detail content */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && (
<EmptyState
icon={FileText}
title="Chọn 1 HĐ ở danh sách bên trái"
description="Chi tiết HĐ + thông tin / góp ý / file đính kèm sẽ hiển thị ở đây."
/>
)}
{selectedId && detail.isLoading && (
<div className="text-sm text-slate-500">Đang tải </div>
)}
{selectedId && detail.data && (
<ContractDetailContent contract={detail.data} onBack={clearSelection} />
)}
</main>
{/* Panel 3 — Workflow + history */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
{/* [S168 GĐ3-D — owner slot (63) chốt D] Chưa chọn HĐ ⇒ cây toàn trình (GĐ3)
thế chỗ ô trống; chọn HĐ thì nhường chỗ cho quy trình duyệt. Trade-off D
(cây rời panel khi chọn HĐ) — sub-implfe-gd3.md §1. */}
{/* [gate F-1/F-2] Panel-3 TỰ CUỘN ⇒ thân cây `lg:max-h-none` giữ đúng 1 thanh cuộn;
sticky/self-start của khuôn vô hiệu ở host display:block này — by-design. */}
{!selectedId && <PipelineTreePanel currentStage={3} bodyMaxHClass="max-h-[28rem] lg:max-h-none" />}
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
</aside>
</div>
</div>