[CLAUDE] FE-User: Tong quan tat ca luong duyet - pipeline 4 giai doan (GD1 PE + GD3 HD live, GD2 De xuat ky ket HD + GD4 sap trien khai)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m58s

- Redesign UserDashboardPage: hero pipeline 4 GD (owner chot ranh b.7-12/13-18/19-21) + KPI ca nhan giu nguyen
- GD1/GD3 doc so lieu that (/purchase-evaluations, /inbox, /reports/my-dashboard); +1 request moi (pageSize=1)
- queryKey ['pe-inbox'] DUNG CHUNG InboxPage + select length (tranh vo cache 2 shape cung key)
- aria-label per-row (group prop bat buoc, WCAG 2.4.6); 100% tieng Viet
- reviewer PASS_WITH_FLAGS 7 FLAG (0H/2M/5L): fix 4, park 2, no-change 1 (disposition trong run-trace)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-29 11:43:25 +07:00
parent 81b510b9c0
commit b5799fc5d3

View File

@ -1,8 +1,47 @@
// "Tổng quan" cho user — overview KPI cá nhân + recent contracts. Khác Inbox // "Tổng quan tất cả luồng duyệt" (fe-user / eOffice) — trang chủ của người dùng.
// (chờ duyệt) — Dashboard cho user nhìn nhanh tất cả HĐ liên quan. // [S159 2026-07-29 — owner chốt qua AskUser] Hero = dải pipeline 4 GIAI ĐOẠN xuyên
// suốt quy trình mua sắm → ký kết hợp đồng:
// GĐ1 Duyệt NCC (bước 1 → 6, CEO chốt) 🟢 LIVE — module PE
// GĐ2 Đề xuất ký kết hợp đồng (bước 7 → 12) ⏳ sắp triển khai
// GĐ3 Duyệt Hợp đồng (bước 13 → 18) 🟢 LIVE — module Contract
// GĐ4 Ký cứng & phát hành (bước 19 → 21) ⏳ sắp triển khai
// Dưới hero GIỮ nguyên KPI cá nhân + 2 danh sách gần đây (chỉ redesign cho hài hoà).
// fe-user ONLY — KHÔNG mirror sang fe-admin (trang này là UX riêng của user).
//
// Nguồn số — chỉ +2 request so với bản cũ:
// · GĐ1 tổng phiếu = `total` của query ['my-pe-recent'] đã có (tái dùng, 0 req)
// · GĐ1 đã duyệt = +1 req GET /purchase-evaluations?pageSize=1&phase=7 → .total
// · GĐ1 chờ tôi = +1 req GET /purchase-evaluations/inbox → .length
// ⚠️ KHÔNG có query-param `pendingMe` ở BE — đó là ENDPOINT RIÊNG trả MẢNG
// phẳng (PurchaseEvaluationsController.cs:30-35); `pendingMe=1` chỉ là URL
// param của trang list. `phase` thì đúng là query-param BE.
// · GĐ3 = `total` của ['my-contracts-recent'] + KPI ['my-dashboard']
// (GetMyDashboardQuery.cs:51-75 chỉ đếm bảng Contracts ⇒ gán vào GĐ3 là đúng nghĩa)
// · GĐ2/GĐ4 = KHÔNG bịa số — badge "Sắp triển khai" + mô tả owner đưa.
//
// ⚠️ Gotcha CSS (index.css): `h1..h4`, `.stat-value`, `.icon-chip` là rule KHÔNG
// nằm trong @layer ⇒ thắng mọi utility Tailwind v4 (layer utilities). Muốn đổi màu
// chữ heading / số thống kê PHẢI dùng dấu `!` (vd `text-brand-800!`).
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { FileText, CheckCircle2, AlertTriangle, Pencil, Clock, Inbox, Coins } from 'lucide-react' import {
AlertTriangle,
ArrowRight,
CheckCircle2,
ChevronDown,
ChevronRight,
ClipboardCheck,
Clock,
Coins,
FileSignature,
FileText,
Hourglass,
Inbox,
Pencil,
RefreshCw,
Stamp,
Workflow,
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { PhaseBadge } from '@/components/PhaseBadge' import { PhaseBadge } from '@/components/PhaseBadge'
import { SlaTimer } from '@/components/SlaTimer' import { SlaTimer } from '@/components/SlaTimer'
@ -16,6 +55,7 @@ import type { ContractListItem } from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms' import { ContractTypeLabel } from '@/types/forms'
import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip' import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip'
import { import {
PurchaseEvaluationPhase,
getPeDisplayStatus, getPeDisplayStatus,
PeDisplayStatusColor, PeDisplayStatusColor,
PeDisplayStatusLabel, PeDisplayStatusLabel,
@ -36,11 +76,200 @@ const fmtMoney = (v: number) => {
return v.toLocaleString('vi-VN') return v.toLocaleString('vi-VN')
} }
// 2026-06-16 redesign (anh "nâng màu, bắt mắt hơn"): KPI cards now carry a /* ─────────────────────────────────────────────────────────────────────────────
// coloured LEFT rail (.card-accent --accent) + a tinted icon-chip + a big stat HERO — dải pipeline 4 giai đoạn
// number. Each tone owns a distinct accent from the new palette so the row ───────────────────────────────────────────────────────────────────────────── */
// scans as 5 colours, not 5 greys. `tone` extended (brand/teal/warn/good/danger
// /violet) — StatCard is local to this page, no external call-site. // 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.
type StageTone = 'brand' | 'teal'
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. `tone` undefined = giai đoạn chưa triển khai (khung mờ).
function StageCard({
index,
title,
stepRange,
icon: Icon,
tone,
children,
}: {
index: number
title: string
stepRange: string
icon: React.ComponentType<{ className?: string }>
tone?: StageTone
children: React.ReactNode
}) {
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 {index}
</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" />
) : 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>
)
}
/* ─────────────────────────────────────────────────────────────────────────────
KPI cá nhân (giữ nguyên từ bản trước — chỉ tinh chỉnh tương phản)
───────────────────────────────────────────────────────────────────────────── */
type StatTone = 'default' | 'teal' | 'warn' | 'good' | 'danger' | 'violet' type StatTone = 'default' | 'teal' | 'warn' | 'good' | 'danger' | 'violet'
const STAT_TONE: Record<StatTone, { rail: string; chipBg: string; chipFg: string }> = { const STAT_TONE: Record<StatTone, { rail: string; chipBg: string; chipFg: string }> = {
@ -72,24 +301,38 @@ function StatCard({
<button <button
onClick={onClick} onClick={onClick}
disabled={!onClick} disabled={!onClick}
className="card-accent flex flex-col p-4 text-left disabled:cursor-default" className="card-accent flex flex-col p-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1 disabled:cursor-default"
style={{ ['--accent' as string]: t.rail }} style={{ ['--accent' as string]: t.rail }}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">{label}</div> <div className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">{label}</div>
<span <span
className="icon-chip h-9 w-9" className="icon-chip"
style={{ ['--chip-bg' as string]: t.chipBg, ['--chip-fg' as string]: t.chipFg }} style={{ ['--chip-bg' as string]: t.chipBg, ['--chip-fg' as string]: t.chipFg }}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
</span> </span>
</div> </div>
<div className="stat-value mt-2 text-3xl">{value}</div> <div className="stat-value mt-2 text-3xl">{value}</div>
{hint && <div className="mt-0.5 text-xs text-slate-400">{hint}</div>} {/* AA: slate-400 chỉ đạt 2.5:1 trên nền trắng → dùng slate-500 (4.8:1). */}
{hint && <div className="mt-0.5 text-xs text-slate-500">{hint}</div>}
</button> </button>
) )
} }
// Tiêu đề section dùng chung — vạch gradient brand + chữ đậm.
function SectionTitle({ children, note }: { children: React.ReactNode; note?: string }) {
return (
<div className="mb-3 flex flex-wrap items-baseline gap-x-2 gap-y-1">
<h2 className="flex items-center gap-2 text-sm">
<span aria-hidden className="h-4 w-1 rounded-full bg-gradient-to-b from-brand-400 to-brand-700" />
{children}
</h2>
{note && <span className="text-[11px] text-slate-500">{note}</span>}
</div>
)
}
export function UserDashboardPage() { export function UserDashboardPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { user } = useAuth() const { user } = useAuth()
@ -113,20 +356,190 @@ export function UserDashboardPage() {
(await api.get<Paged<PeListItem>>('/purchase-evaluations', { params: { page: 1, pageSize: 5 } })).data, (await api.get<Paged<PeListItem>>('/purchase-evaluations', { params: { page: 1, pageSize: 5 } })).data,
}) })
// [S159] +1 req — 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 s = stats.data
const peApproved = peApprovedQuery.data
const hasError =
stats.isError || recent.isError || recentPe.isError || peApprovedQuery.isError || peInboxQuery.isError
const retryFailed = () => {
if (stats.isError) void stats.refetch()
if (recent.isError) void recent.refetch()
if (recentPe.isError) void recentPe.refetch()
if (peApprovedQuery.isError) void peApprovedQuery.refetch()
if (peInboxQuery.isError) void peInboxQuery.refetch()
}
return ( return (
<div className="p-6"> <div className="p-6">
<PageHeader <PageHeader
title={`Xin chào, ${user?.fullName ?? user?.email ?? 'bạn'}`} title="Tổng quan tất cả luồng duyệt"
description="Tổng quan công việc HĐ của bạn — click vào card để xem chi tiết." 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
index={1}
title="Duyệt NCC"
stepRange="Bước 1 → 6 · CEO chốt"
icon={ClipboardCheck}
tone="brand"
>
<div className="space-y-0.5">
<StageStatRow
group="Duyệt NCC"
label="Tổng phiếu"
value={recentPe.data?.total}
tone="brand"
loading={recentPe.isLoading}
error={recentPe.isError}
onClick={() => navigate('/purchase-evaluations')}
/>
<StageStatRow
group="Duyệt NCC"
label="Đã duyệt"
value={peApproved}
tone="brand"
loading={peApprovedQuery.isLoading}
error={peApprovedQuery.isError}
onClick={() => navigate(`/purchase-evaluations?phase=${PurchaseEvaluationPhase.DaDuyet}`)}
/>
<StageStatRow
group="Duyệt NCC"
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 index={2} title="Đề xuất ký kết hợp đồng" stepRange="Bước 7 → 12" icon={FileSignature}>
<StageSoon
description="Phiếu nối tiếp từ phiếu Duyệt NCC đã duyệt — chốt giá trị ký kết."
hint={
typeof peApproved === 'number' && peApproved > 0
? `${peApproved} phiếu Duyệt NCC đã duyệt`
: undefined
}
/>
</StageCard>
<StageArrow />
{/* GĐ3 — LIVE */}
<StageCard
index={3}
title="Duyệt Hợp đồng"
stepRange="Bước 13 → 18"
icon={FileText}
tone="teal"
>
<div className="space-y-0.5">
<StageStatRow
group="Duyệt Hợp đồng"
label="Tổng hợp đồng"
value={recent.data?.total}
tone="teal"
loading={recent.isLoading}
error={recent.isError}
onClick={() => navigate('/my-contracts')}
/>
<StageStatRow
group="Duyệt Hợp đồng"
label="Chờ tôi duyệt"
value={s?.pendingMyApproval}
tone="teal"
loading={stats.isLoading}
error={stats.isError}
emphasis="accent"
onClick={() => navigate('/inbox')}
/>
<StageStatRow
group="Duyệt Hợp đồng"
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 index={4} title="Ký cứng & phát hành" stepRange="Bước 19 → 21" icon={Stamp}>
<StageSoon description="Tải lên bản hợp đồng đã ký cứng." />
</StageCard>
</div>
</section>
{/* ── KPI cá nhân ──────────────────────────────────────────────────── */}
<section className="mb-6"> <section className="mb-6">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold text-slate-800"> <SectionTitle note="Số liệu hợp đồng của riêng bạn (giai đoạn 3)">Việc của tôi</SectionTitle>
<span aria-hidden className="h-4 w-1 rounded-full bg-gradient-to-b from-brand-400 to-brand-700" />
Của tôi
</h2>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5"> <div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<StatCard <StatCard
icon={Pencil} icon={Pencil}
@ -168,142 +581,186 @@ export function UserDashboardPage() {
</div> </div>
</section> </section>
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> {/* ── 2 danh sách gần đây (trước đây 2 khối dính sát nhau — nay đặt trong
<header className="flex items-center justify-between border-b border-slate-200 bg-gradient-to-r from-brand-50/70 to-transparent px-4 py-3"> lưới có khoảng cách, xếp cạnh nhau ở màn rộng) ─────────────────── */}
<h2 className="flex items-center gap-2 text-sm font-bold text-slate-800"> <div className="grid gap-4 xl:grid-cols-2">
<span className="icon-chip h-7 w-7"> <section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<FileText className="h-4 w-4" /> <header className="flex items-center justify-between gap-2 border-b border-slate-200 bg-gradient-to-r from-brand-50/70 to-transparent px-4 py-3">
</span> <h2 className="flex items-center gap-2 text-sm">
gần đây <span
</h2> className="icon-chip"
<Button variant="outline" onClick={() => navigate('/my-contracts')}> style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
Xem tất cả
</Button>
</header>
{recent.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-14 animate-pulse rounded-md bg-slate-100" />
))}
</div>
)}
{!recent.isLoading && (recent.data?.items?.length ?? 0) === 0 && (
<div className="p-6">
<EmptyState
icon={FileText}
title="Chưa có HĐ nào"
description="Tạo HĐ đầu tiên để bắt đầu."
action={
<Button onClick={() => navigate('/contracts/new')}>
<CheckCircle2 className="mr-1 h-4 w-4" />
Tạo mới
</Button>
}
/>
</div>
)}
<ul className="divide-y divide-slate-100">
{recent.data?.items?.map(c => (
<li key={c.id}>
<button
onClick={() => navigate(`/my-contracts?id=${c.id}`)}
className="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-slate-50"
> >
<div className="min-w-0 flex-1"> <FileText className="h-4 w-4" />
<div className="flex items-center gap-2"> </span>
<span className="truncate text-sm font-medium text-slate-900"> gần đây
{c.tenHopDong ?? '(chưa đặt tên)'} </h2>
</span> <Button variant="outline" onClick={() => navigate('/my-contracts')}>
<PhaseBadge phase={c.phase} className="shrink-0 text-[10px]" /> Xem tất cả
</div> </Button>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500"> </header>
<span className="font-mono">{c.maHopDong ?? '—'}</span>
<span>·</span>
<span>{ContractTypeLabel[c.type] ?? '—'}</span>
<span>·</span>
<span className="truncate">{c.supplierName}</span>
</div>
</div>
<div className="ml-3 shrink-0 text-right">
<div className="text-sm text-slate-700">{fmtMoney(c.giaTri)}</div>
<SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} />
</div>
</button>
</li>
))}
</ul>
</section>
{/* [S89 — anh Kiệt FDC] Phiếu Duyệt NCC gần đây — mỗi row có PhaseBadge (PE {recent.isLoading && (
display-status) + PeFinalizeChip ("quy trình kết thúc, không lên CEO") + <div className="space-y-2 p-3">
tên gói + mã phiếu. PE phase ≠ Contract phase nên dùng PE display-status pill. */} {Array.from({ length: 3 }).map((_, i) => (
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> <div key={i} className="h-14 animate-pulse rounded-md bg-slate-100" />
<header className="flex items-center justify-between border-b border-slate-100 px-4 py-3"> ))}
<h2 className="flex items-center gap-2 text-sm font-semibold text-slate-900"> </div>
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-violet-50 text-violet-600"> )}
<Inbox className="h-4 w-4" />
</span>
Phiếu Duyệt NCC gần đây
</h2>
<Button variant="outline" onClick={() => navigate('/purchase-evaluations')}>
Xem tất cả
</Button>
</header>
{recentPe.isLoading && ( {recent.isError && (
<div className="space-y-2 p-3"> <div className="p-6">
{Array.from({ length: 3 }).map((_, i) => ( <EmptyState
<div key={i} className="h-14 animate-pulse rounded-md bg-slate-100" /> icon={AlertTriangle}
title="Không tải được danh sách hợp đồng"
description="Kiểm tra kết nối rồi thử lại."
action={
<Button variant="outline" onClick={() => void recent.refetch()}>
<RefreshCw className="h-3.5 w-3.5" />
Thử lại
</Button>
}
/>
</div>
)}
{!recent.isLoading && !recent.isError && (recent.data?.items?.length ?? 0) === 0 && (
<div className="p-6">
<EmptyState
icon={FileText}
title="Chưa có HĐ nào"
description="Tạo HĐ đầu tiên để bắt đầu."
action={
<Button onClick={() => navigate('/contracts/new')}>
<CheckCircle2 className="h-4 w-4" />
Tạo mới
</Button>
}
/>
</div>
)}
<ul className="divide-y divide-slate-100">
{recent.data?.items?.map(c => (
<li key={c.id}>
<button
onClick={() => navigate(`/my-contracts?id=${c.id}`)}
className="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-slate-900">
{c.tenHopDong ?? '(chưa đặt tên)'}
</span>
<PhaseBadge phase={c.phase} className="shrink-0 text-[10px]" />
</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
<span className="font-mono">{c.maHopDong ?? '—'}</span>
<span>·</span>
<span>{ContractTypeLabel[c.type] ?? '—'}</span>
<span>·</span>
<span className="truncate">{c.supplierName}</span>
</div>
</div>
<div className="ml-3 shrink-0 text-right">
<div className="text-sm text-slate-700">{fmtMoney(c.giaTri)}</div>
<SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} />
</div>
</button>
</li>
))} ))}
</div> </ul>
)} </section>
{!recentPe.isLoading && (recentPe.data?.items?.length ?? 0) === 0 && ( {/* [S89 — anh Kiệt FDC] Phiếu Duyệt NCC gần đây — mỗi row có PeFinalizeChip
<div className="p-6"> ("quy trình kết thúc, không lên CEO") + PE display-status pill + tên gói
<EmptyState + mã phiếu. PE phase ≠ Contract phase nên KHÔNG dùng PhaseBadge. */}
icon={Inbox} <section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
title="Chưa có phiếu nào" <header className="flex items-center justify-between gap-2 border-b border-slate-200 bg-gradient-to-r from-violet-50/70 to-transparent px-4 py-3">
description="Phiếu Duyệt NCC gần đây sẽ hiển thị tại đây." <h2 className="flex items-center gap-2 text-sm">
/> <span
</div> className="icon-chip"
)} style={{ ['--chip-bg' as string]: 'var(--color-violet-50)', ['--chip-fg' as string]: 'var(--color-violet-700)' }}
<ul className="divide-y divide-slate-100">
{recentPe.data?.items?.map(p => (
<li key={p.id}>
<button
onClick={() => navigate(`/purchase-evaluations/${p.id}`)}
className="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-slate-50"
> >
<div className="min-w-0 flex-1"> <Inbox className="h-4 w-4" />
<div className="flex min-w-0 items-center gap-2"> </span>
<span className="truncate text-sm font-medium text-slate-900"> Phiếu Duyệt NCC gần đây
{p.tenGoiThau} </h2>
</span> <Button variant="outline" onClick={() => navigate('/purchase-evaluations')}>
<PeFinalizeChip endsBeforeCeo={p.endsBeforeCeo} finalizeStepName={p.finalizeStepName} phase={p.phase} /> Xem tất cả
<span </Button>
className={cn( </header>
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1 ring-inset ring-current/15',
PeDisplayStatusColor[getPeDisplayStatus(p.phase)], {recentPe.isLoading && (
)} <div className="space-y-2 p-3">
> {Array.from({ length: 3 }).map((_, i) => (
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]} <div key={i} className="h-14 animate-pulse rounded-md bg-slate-100" />
</span> ))}
</div>
)}
{recentPe.isError && (
<div className="p-6">
<EmptyState
icon={AlertTriangle}
title="Không tải được danh sách phiếu"
description="Kiểm tra kết nối rồi thử lại."
action={
<Button variant="outline" onClick={() => void recentPe.refetch()}>
<RefreshCw className="h-3.5 w-3.5" />
Thử lại
</Button>
}
/>
</div>
)}
{!recentPe.isLoading && !recentPe.isError && (recentPe.data?.items?.length ?? 0) === 0 && (
<div className="p-6">
<EmptyState
icon={Inbox}
title="Chưa có phiếu nào"
description="Phiếu Duyệt NCC gần đây sẽ hiển thị tại đây."
/>
</div>
)}
<ul className="divide-y divide-slate-100">
{recentPe.data?.items?.map(p => (
<li key={p.id}>
<button
onClick={() => navigate(`/purchase-evaluations/${p.id}`)}
className="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500"
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium text-slate-900">{p.tenGoiThau}</span>
<PeFinalizeChip
endsBeforeCeo={p.endsBeforeCeo}
finalizeStepName={p.finalizeStepName}
phase={p.phase}
/>
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1 ring-inset ring-current/15',
PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
)}
>
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
<span className="font-mono">{p.maPhieu ?? '—'}</span>
<span>·</span>
<span className="truncate">{p.projectName}</span>
</div>
</div> </div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500"> </button>
<span className="font-mono">{p.maPhieu ?? '—'}</span> </li>
<span>·</span> ))}
<span className="truncate">{p.projectName}</span> </ul>
</div> </section>
</div> </div>
</button>
</li>
))}
</ul>
</section>
</div> </div>
) )
} }