[CLAUDE] Contract+FE-User: dot-5 menu 6-leaf y-chang-Duyet-NCC + GET /contracts/deleted + bo hero + "Tong quan quy trinh"
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m38s

- BE: ListDeletedContractsQuery (IgnoreQueryFilters + Where(IsDeleted) + IDOR mirror; LUAT thay so-dem theo reviewer FLAG-10) + GET /contracts/deleted [Contracts.Read]
- Seeder: Ct x7 +3 leaf (WfView/Approved/Deleted) + labelBackfill "Dang duyet" x7 + Khkk 6 leaf + Dashboard -> "Tong quan quy trinh" + reviewKeys +23 key
- FE user: matrix +type=3 (HD chung 1 bo V2) + route /contracts/workflow-matrix + Ct regex +3 action + MyContractsPage (phase/deleted + banner + an nut Tao + FLAG-2 guard selectContract khi deleted) + MenuLeaf depth-0 UPPERCASE + UserDashboardPage BO HERO (owner: "bo cai tren di, cai duoi OK") + title "Tong quan quy trinh"
- fe-admin: staticMap +2 Khkk (FLAG-1); leaf Ct_ moi van an theo isAdminHidden (da kiem CLEAN)
- reviewer PASS_WITH_FLAGS 10 FLAG (0H/4M/6L): fix 4M, park 6L co ghi (FLAG-4 dinh nghia "Da duyet" HD -> cho owner)
- build fe x2 + BE PASS; test 562 PASS

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-29 14:40:10 +07:00
parent ceeebf3c3b
commit ea962b1e59
10 changed files with 173 additions and 371 deletions

View File

@ -61,6 +61,8 @@ function App() {
<Route path="/my-contracts" element={<MyContractsPage />} />
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
<Route path="/purchase-evaluations/workflow-matrix" element={<WorkflowMatrixViewPage />} />
{/* [S159-đợt5] alias cho leaf "Luồng duyệt" của 7 nhóm HĐ (?type=3) */}
<Route path="/contracts/workflow-matrix" element={<WorkflowMatrixViewPage />} />
<Route path="/purchase-evaluations/workspace" element={<PurchaseEvaluationWorkspacePage />} />
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />

View File

@ -75,6 +75,9 @@ function resolvePath(key: string): string | null {
// LESSON gotcha #50: key DB mới PHẢI có route ở đây, không thì sidebar drop SILENT.
KeHoachKyKet: '/dashboard',
HopDongCung: '/dashboard',
// [S159-đợt5] 2 leaf mới của skeleton GĐ2 (đủ 6 y chang Duyệt NCC)
Khkk_WfView: '/coming-soon?stage=2&m=wfview',
Khkk_Deleted: '/coming-soon?stage=2&m=deleted',
// [Plan CA Hotfix 1 S29 2026-05-22] 4 master + 4 catalog leaf moved từ
// fe-admin → fe-user. resolvePath PHẢI có route mapping nếu không
// MenuLeaf line 238 `if (!path) return null` → sidebar drop silent.
@ -124,7 +127,9 @@ function resolvePath(key: string): string | null {
}
if (staticMap[key]) return staticMap[key]
const match = key.match(/^Ct_([^_]+)_(List|Create|Pending)$/)
// [S159-đợt5 owner "y chang Duyệt NCC"] +WfView/Approved/Deleted. WfView cả 7 loại
// đều ?type=3 (HĐ chung 1 bộ workflow V2) — thêm &ct=N để active-state riêng từng leaf.
const match = key.match(/^Ct_([^_]+)_(List|Create|Pending|WfView|Approved|Deleted)$/)
if (match) {
const [, code, action] = match
const typeInt = TYPE_CODE_TO_INT[code]
@ -132,6 +137,9 @@ function resolvePath(key: string): string | null {
if (action === 'List') return `/my-contracts?type=${typeInt}`
if (action === 'Create') return `/contracts/new?type=${typeInt}`
if (action === 'Pending') return `/inbox?type=${typeInt}`
if (action === 'WfView') return `/contracts/workflow-matrix?type=3&ct=${typeInt}`
if (action === 'Approved') return `/my-contracts?type=${typeInt}&phase=9`
if (action === 'Deleted') return `/my-contracts?type=${typeInt}&deleted=1`
}
// Pe_<Code>_<Action> cho module Duyệt NCC (user side)
@ -343,7 +351,15 @@ function MenuLeaf({ node, depth }: { node: MenuNode; depth: number }) {
// Density-first: active leaf gets a brand left-rail + tint (crisp
// selected affordance, NAMGROUP). Inactive stays quiet slate.
'relative block rounded-md leading-snug transition-colors',
isDeep ? 'px-3 py-1 text-[11px]' : 'px-3 py-1.5 text-[12px] font-medium',
// [S159-đợt5 owner] leaf cấp-0 render qua MenuLeaf ("Tổng quan quy trình") viết
// HOA đồng bộ group header cấp-0 (khuôn MenuGroup :260). 🔴 KHAI ĐÚNG PHẠM VI
// (reviewer FLAG-3): "Hộp thư" KHÔNG đi qua đây — nó là USER_FIXED_TOP render
// bằng StaticLeaf (lớp CSS riêng, vẫn chữ thường). Đồng bộ nốt = quyết owner.
depth === 0
? 'px-3 py-2 text-[11px] font-semibold uppercase tracking-wide'
: isDeep
? 'px-3 py-1 text-[11px]'
: 'px-3 py-1.5 text-[12px] font-medium',
isActive
? 'bg-brand-50 font-semibold text-brand-700 before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full before:bg-brand-600'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',

View File

@ -52,7 +52,6 @@ import { useNavigate } from 'react-router-dom'
import {
AlertTriangle,
ArrowRight,
ChevronDown,
ChevronLeft,
ChevronRight,
ClipboardCheck,
@ -65,7 +64,6 @@ import {
Search,
SearchX,
Stamp,
Workflow,
X,
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
@ -82,7 +80,6 @@ import { ContractTypeLabel } from '@/types/forms'
import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip'
import { PeUrgentChips } from '@/components/pe/PeUrgentChips'
import {
PurchaseEvaluationPhase,
getPeDisplayStatus,
PeDisplayStatus,
PeDisplayStatusColor,
@ -90,14 +87,6 @@ import {
type PeListItem,
} from '@/types/purchaseEvaluation'
type MyDashboard = {
draftsInProgress: number
pendingMyApproval: number
dueSoon: number
overdue: number
draftsTotalValue: number
}
const PAGE_SIZE = 10
const fmtMoney = (v: number) => {
@ -153,177 +142,6 @@ const stageOf = (n: number) => STAGES.find(s => s.n === n) ?? STAGES[0]
HERO — dải pipeline 4 giai đoạn (GIỮ NGUYÊN, owner đã duyệt)
───────────────────────────────────────────────────────────────────────────── */
// Giai đoạn LIVE mang accent (brand / teal); giai đoạn "sắp triển khai" = khung
// nét đứt xám trung tính — MỜ nhưng KHÔNG đỏ/vàng, để không đọc nhầm thành lỗi.
const STAGE_TONE: Record<StageTone, {
rail: string; chipBg: string; chipFg: string
badge: string; head: string; row: string; num: string; link: string
}> = {
brand: {
rail: 'var(--color-brand-500)',
chipBg: 'var(--color-brand-50)',
chipFg: 'var(--color-brand-600)',
badge: 'bg-brand-50 text-brand-700 ring-brand-200',
head: 'text-brand-800!',
row: 'hover:bg-brand-50 focus-visible:ring-brand-500',
num: 'text-brand-700!',
link: 'text-brand-700 hover:text-brand-800 focus-visible:ring-brand-500',
},
teal: {
rail: 'var(--color-teal-500)',
chipBg: 'var(--color-teal-50)',
chipFg: 'var(--color-teal-700)',
badge: 'bg-teal-50 text-teal-700 ring-teal-100',
head: 'text-teal-700!',
row: 'hover:bg-teal-50 focus-visible:ring-teal-500',
num: 'text-teal-700!',
link: 'text-teal-700 hover:text-teal-600 focus-visible:ring-teal-500',
},
}
// Khung 1 giai đoạn — nhãn/icon/tone đọc TỪ `STAGES`, không truyền chuỗi rời.
function StageCard({ stage, children }: { stage: StageMeta; children: React.ReactNode }) {
const { n, title, stepRange, icon: Icon, tone } = stage
const t = tone ? STAGE_TONE[tone] : null
return (
<div
className={cn(
'flex h-full flex-col rounded-xl p-3.5',
t ? 'card-accent' : 'border border-dashed border-slate-300 bg-slate-50',
)}
style={t ? { ['--accent' as string]: t.rail } : undefined}
>
<div className="flex items-start justify-between gap-2">
<span
className={cn(
'rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ring-1 ring-inset',
t ? t.badge : 'bg-white text-slate-600 ring-slate-300',
)}
>
Giai đoạn {n}
</span>
<span
className="icon-chip shrink-0"
style={
t
? { ['--chip-bg' as string]: t.chipBg, ['--chip-fg' as string]: t.chipFg }
: { ['--chip-bg' as string]: '#ffffff', ['--chip-fg' as string]: '#64748b' }
}
>
<Icon className="h-4 w-4" />
</span>
</div>
<h3 className={cn('mt-2 text-[15px] leading-tight', t ? t.head : 'text-slate-600!')}>{title}</h3>
<div className="mt-1 text-[11px] font-medium text-slate-500">{stepRange}</div>
<div className="mt-3 flex-1">{children}</div>
</div>
)
}
// Mũi tên nối giữa 2 giai đoạn — ngang trên desktop, xuống dòng trên mobile.
function StageArrow() {
return (
<div aria-hidden className="flex items-center justify-center py-0.5 lg:px-0.5">
<ChevronDown className="h-4 w-4 text-slate-400 lg:hidden" />
<ChevronRight className="hidden h-5 w-5 text-slate-400 lg:block" />
</div>
)
}
// 1 dòng số liệu trong giai đoạn LIVE — bấm được, mở đúng màn hình đã lọc sẵn.
// Là <button> ĐỘC LẬP (không lồng trong button cha) để giữ HTML/a11y hợp lệ.
function StageStatRow({
group,
label,
value,
tone,
loading,
error,
emphasis,
onClick,
}: {
// Tên giai đoạn — vào aria-label để 2 nút cùng chữ "Chờ tôi duyệt" ở GĐ1/GĐ3
// đọc ra KHÁC nhau trên screen-reader (reviewer FLAG-1, WCAG 2.4.6).
group: string
label: string
value: number | undefined
tone: StageTone
loading: boolean
error: boolean
emphasis?: 'accent' | 'danger'
onClick: () => void
}) {
const t = STAGE_TONE[tone]
const highlight = !loading && !error && typeof value === 'number' && value > 0 && emphasis
return (
<button
type="button"
aria-label={`${group}${label}${typeof value === 'number' ? `: ${value}` : ''}`}
onClick={onClick}
className={cn(
// py-1.5 → vùng bấm ~32px cao, thoải mái trên cảm ứng (WCAG 2.5.8 tối thiểu 24px).
'flex w-full items-center justify-between gap-2 rounded-md px-1.5 py-1.5 text-left transition',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
t.row,
)}
>
<span className="min-w-0 text-[11px] font-medium leading-tight text-slate-600">{label}</span>
{loading ? (
<span aria-hidden className="h-4 w-7 animate-pulse rounded bg-slate-200 motion-reduce:animate-none" />
) : error ? (
<span className="text-sm font-semibold text-slate-500" title="Không tải được số liệu">
</span>
) : (
<span
className={cn(
'stat-value shrink-0 text-base tabular-nums',
highlight === 'accent' && t.num,
highlight === 'danger' && 'text-accent-600!',
)}
>
{value ?? 0}
</span>
)}
</button>
)
}
// Link mở module của giai đoạn LIVE.
function StageLink({ label, tone, onClick }: { label: string; tone: StageTone; onClick: () => void }) {
const t = STAGE_TONE[tone]
return (
<button
type="button"
onClick={onClick}
className={cn(
'mt-2 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-semibold transition',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
t.link,
)}
>
{label}
<ArrowRight className="h-3.5 w-3.5" />
</button>
)
}
// Thân của giai đoạn chưa triển khai — badge trung tính + 1 dòng mô tả.
function StageSoon({ description, hint }: { description?: string; hint?: string }) {
return (
<div>
<span className="inline-flex items-center gap-1 rounded-full bg-slate-200/80 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600">
<Hourglass className="h-3 w-3" />
Sắp triển khai
</span>
<p className="mt-2 text-[11px] leading-relaxed text-slate-600">{description}</p>
{hint && <p className="mt-1.5 text-[11px] font-semibold text-slate-500">{hint}</p>}
</div>
)
}
/* ─────────────────────────────────────────────────────────────────────────────
TẦNG LIÊN KẾT 2 — "Hành trình": mini-strip 4 chấm trên TỪNG DÒNG.
Chấm của chính giai đoạn dòng đó mang trạng thái phiếu; 3 chấm còn lại mờ +
@ -1084,11 +902,6 @@ export function UserDashboardPage() {
/* ── Số liệu hero (5 query nền, y như trước — chỉ 2 query list rút pageSize 5 → 1
vì 2 danh sách "gần đây" đã bị gỡ, hero chỉ cần `.total`). ───────────── */
const stats = useQuery({
queryKey: ['my-dashboard'],
queryFn: async () => (await api.get<MyDashboard>('/reports/my-dashboard')).data,
})
const peTotalQuery = useQuery({
queryKey: ['pipeline-pe-total'],
queryFn: async () =>
@ -1102,46 +915,6 @@ export function UserDashboardPage() {
(await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 1 } })).data.total,
})
// [S159] số phiếu Duyệt NCC ĐÃ DUYỆT (phase=7). pageSize=1 vì chỉ cần `total`.
const peApprovedQuery = useQuery({
queryKey: ['pipeline-pe-approved'],
queryFn: async () =>
(
await api.get<Paged<PeListItem>>('/purchase-evaluations', {
params: { page: 1, pageSize: 1, phase: PurchaseEvaluationPhase.DaDuyet },
})
).data.total,
})
// [S159] phiếu Duyệt NCC đang chờ CHÍNH TÔI duyệt. Endpoint /inbox trả mảng phẳng.
// 🔴 DÙNG CHUNG queryKey + queryFn với InboxPage:77-78 (reviewer FLAG-5) — cache chia sẻ,
// 2 màn luôn khớp số. CẤM đổi queryFn trả `.length` dưới key này: InboxPage cache MẢNG,
// trùng key khác shape = vỡ cache lẫn nhau ⇒ đếm bằng `select` (transform per-observer,
// không chạm cache).
const peInboxQuery = useQuery({
queryKey: ['pe-inbox'],
queryFn: async () => (await api.get<PeListItem[]>('/purchase-evaluations/inbox')).data,
select: d => d.length,
})
const s = stats.data
const peApproved = peApprovedQuery.data
const hasError =
stats.isError ||
contractTotalQuery.isError ||
peTotalQuery.isError ||
peApprovedQuery.isError ||
peInboxQuery.isError
const retryFailed = () => {
if (stats.isError) void stats.refetch()
if (contractTotalQuery.isError) void contractTotalQuery.refetch()
if (peTotalQuery.isError) void peTotalQuery.refetch()
if (peApprovedQuery.isError) void peApprovedQuery.refetch()
if (peInboxQuery.isError) void peInboxQuery.refetch()
}
/* ── Section "Danh sách phiếu toàn trình" ──────────────────────────────── */
const [stage, setStage] = useState(1)
@ -1219,135 +992,13 @@ export function UserDashboardPage() {
return (
<div className="p-6">
<PageHeader
title="Tổng quan tất cả luồng duyệt"
title="Tổng quan quy trình"
description={`Xin chào ${user?.fullName ?? user?.email ?? 'bạn'} — toàn bộ hành trình từ Duyệt NCC đến Ký kết hợp đồng, 4 giai đoạn. Bấm vào số liệu để mở đúng danh sách.`}
/>
{/* ── HERO: pipeline 4 giai đoạn ───────────────────────────────────── */}
<section className="mb-6 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<header className="mb-3 flex items-center gap-2.5">
<span className="icon-chip shrink-0">
<Workflow className="h-4 w-4" />
</span>
<div className="min-w-0">
{/* Không dùng .label-eyebrow (brand-600 = 4.42:1, hụt AA 4.5) — brand-700
giữ đúng nhận diện mà đạt 5.6:1. */}
<div className="text-[11px] font-bold uppercase tracking-[0.07em] text-brand-700">
Quy trình mua sắm kết hợp đng
</div>
<h2 className="text-sm leading-tight">Toàn trình 4 giai đoạn · 21 bước</h2>
</div>
</header>
{hasError && (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg border border-amberx-500/40 bg-amberx-50 px-3 py-2 text-xs text-amberx-700">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1">
Không tải đưc một vài số liệu. Các phần còn lại vẫn hiển thị bình thường.
</span>
<Button variant="outline" size="sm" onClick={retryFailed}>
<RefreshCw className="h-3.5 w-3.5" />
Thử lại
</Button>
</div>
)}
<div className="grid grid-cols-1 gap-2 lg:grid-cols-[1fr_auto_1fr_auto_1fr_auto_1fr] lg:items-stretch lg:gap-1.5">
{/* GĐ1 — LIVE */}
<StageCard stage={STAGES[0]}>
<div className="space-y-0.5">
<StageStatRow
group={STAGES[0].title}
label="Tổng phiếu"
value={peTotalQuery.data}
tone="brand"
loading={peTotalQuery.isLoading}
error={peTotalQuery.isError}
onClick={() => navigate('/purchase-evaluations')}
/>
<StageStatRow
group={STAGES[0].title}
label="Đã duyệt"
value={peApproved}
tone="brand"
loading={peApprovedQuery.isLoading}
error={peApprovedQuery.isError}
onClick={() => navigate(`/purchase-evaluations?phase=${PurchaseEvaluationPhase.DaDuyet}`)}
/>
<StageStatRow
group={STAGES[0].title}
label="Chờ tôi duyệt"
value={peInboxQuery.data}
tone="brand"
loading={peInboxQuery.isLoading}
error={peInboxQuery.isError}
emphasis="accent"
onClick={() => navigate('/purchase-evaluations?pendingMe=1')}
/>
</div>
<StageLink label="Mở Duyệt NCC" tone="brand" onClick={() => navigate('/purchase-evaluations')} />
</StageCard>
<StageArrow />
{/* GĐ2 — sắp triển khai */}
<StageCard stage={STAGES[1]}>
<StageSoon
description={STAGES[1].soonDesc}
hint={
typeof peApproved === 'number' && peApproved > 0
? `${peApproved} phiếu Duyệt NCC đã duyệt`
: undefined
}
/>
</StageCard>
<StageArrow />
{/* GĐ3 — LIVE */}
<StageCard stage={STAGES[2]}>
<div className="space-y-0.5">
<StageStatRow
group={STAGES[2].title}
label="Tổng hợp đồng"
value={contractTotalQuery.data}
tone="teal"
loading={contractTotalQuery.isLoading}
error={contractTotalQuery.isError}
onClick={() => navigate('/my-contracts')}
/>
<StageStatRow
group={STAGES[2].title}
label="Chờ tôi duyệt"
value={s?.pendingMyApproval}
tone="teal"
loading={stats.isLoading}
error={stats.isError}
emphasis="accent"
onClick={() => navigate('/inbox')}
/>
<StageStatRow
group={STAGES[2].title}
label="Đã quá hạn"
value={s?.overdue}
tone="teal"
loading={stats.isLoading}
error={stats.isError}
emphasis="danger"
onClick={() => navigate('/inbox')}
/>
</div>
<StageLink label="Mở Hợp đồng" tone="teal" onClick={() => navigate('/my-contracts')} />
</StageCard>
<StageArrow />
{/* GĐ4 — sắp triển khai */}
<StageCard stage={STAGES[3]}>
<StageSoon description={STAGES[3].soonDesc} />
</StageCard>
</div>
</section>
{/* [S159-dot5 owner: "O dep day, vay bo cai tren di nhe, cai duoi OK day"]
HERO pipeline 4 card DA GO — tang tong-quan-lien-ket nay la thanh tab
4 GD cua Danh sach chi tiet ben duoi (count + cot Hanh trinh per-row). */}
{/* ── DANH SÁCH PHIẾU TOÀN TRÌNH ───────────────────────────────────── */}
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">

View File

@ -29,11 +29,24 @@ export function MyContractsPage() {
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
const selectedId = searchParams.get('id')
const search = searchParams.get('q') ?? ''
// [S159-đợt5] leaf 'Đã duyệt' (?phase=9 — BE /contracts có sẵn param phase) và
// 'Đã xóa' (?deleted=1 — endpoint /contracts/deleted mới, chỉ-xem).
const phaseFilter = searchParams.get('phase') ? Number(searchParams.get('phase')) : null
const showDeleted = searchParams.get('deleted') === '1'
const list = useQuery({
queryKey: ['my-contracts', typeFilter],
queryKey: ['my-contracts', typeFilter, phaseFilter, showDeleted],
queryFn: async () =>
(await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data,
(
await api.get<Paged<ContractListItem>>(showDeleted ? '/contracts/deleted' : '/contracts', {
params: {
page: 1,
pageSize: 100,
...(showDeleted && typeFilter != null ? { type: typeFilter } : {}),
...(!showDeleted && phaseFilter != null ? { phase: phaseFilter } : {}),
},
})
).data,
})
const detail = useQuery({
@ -57,6 +70,10 @@ export function MyContractsPage() {
}, [list.data, typeFilter, search])
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).
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
@ -96,10 +113,22 @@ export function MyContractsPage() {
{rows.length}
</span>
</div>
<Button onClick={() => navigate(typeFilter ? `/contracts/new?type=${typeFilter}` : '/contracts/new')}>
<Plus className="h-4 w-4" />
Tạo mới
</Button>
{showDeleted && (
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-semibold text-slate-600 ring-1 ring-inset ring-slate-300">
Đã xóa chỉ xem
</span>
)}
{!showDeleted && phaseFilter === 9 && (
<span className="rounded-full bg-teal-50 px-2.5 py-1 text-[11px] font-semibold text-teal-700 ring-1 ring-inset ring-teal-200">
Đã phát hành
</span>
)}
{!showDeleted && (
<Button onClick={() => navigate(typeFilter ? `/contracts/new?type=${typeFilter}` : '/contracts/new')}>
<Plus className="h-4 w-4" />
Tạo mới
</Button>
)}
</header>
{/* 3-panel grid — flex-1 để fill phần còn lại của viewport */}
@ -130,7 +159,7 @@ export function MyContractsPage() {
<EmptyState
icon={FileText}
title="Không có HĐ"
description={typeFilter != null ? 'Loại HĐ này chưa có dữ liệu.' : 'Tạo HĐ mới để bắt đầu.'}
description={showDeleted ? 'Chưa có HĐ nào bị xóa.' : typeFilter != null ? 'Loại HĐ này chưa có dữ liệu.' : 'Tạo HĐ mới để bắt đầu.'}
/>
</div>
)}

View File

@ -25,6 +25,7 @@ import type { AwAdminOverviewDto, AwDefinitionDto, AwLevelDto } from '@/types/ap
const TYPE_LABEL: Record<number, string> = {
1: 'Duyệt NCC',
2: 'Duyệt NCC + Giải pháp',
3: 'Hợp đồng (chung mọi loại)', // [S159-đợt5]
}
// Color palette per Step (Phòng) — cycle 5 màu để phân biệt Bước. Tailwind JIT
@ -50,7 +51,9 @@ const LEVEL_PALETTE = [
export function WorkflowMatrixViewPage() {
const [searchParams] = useSearchParams()
const rawType = Number(searchParams.get('type'))
const typeInt = rawType === 1 || rawType === 2 ? rawType : 1
// [S159-đợt5] +3 = Contract (ApplicableType.Contract — HĐ dùng CHUNG 1 bộ workflow V2
// cho mọi ContractType ⇒ 7 leaf 'Luồng duyệt' của 7 nhóm HĐ đều trỏ về matrix này).
const typeInt = rawType === 1 || rawType === 2 || rawType === 3 ? rawType : 1
const { data, isLoading, isError } = useQuery({
queryKey: ['workflow-matrix', typeInt],