[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
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:
@ -1,8 +1,47 @@
|
||||
// "Tổng quan" cho user — overview KPI cá nhân + recent contracts. Khác Inbox
|
||||
// (chờ duyệt) — Dashboard cho user nhìn nhanh tất cả HĐ liên quan.
|
||||
// "Tổng quan tất cả luồng duyệt" (fe-user / eOffice) — trang chủ của người dùng.
|
||||
// [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 { 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 { PhaseBadge } from '@/components/PhaseBadge'
|
||||
import { SlaTimer } from '@/components/SlaTimer'
|
||||
@ -16,6 +55,7 @@ import type { ContractListItem } from '@/types/contracts'
|
||||
import { ContractTypeLabel } from '@/types/forms'
|
||||
import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip'
|
||||
import {
|
||||
PurchaseEvaluationPhase,
|
||||
getPeDisplayStatus,
|
||||
PeDisplayStatusColor,
|
||||
PeDisplayStatusLabel,
|
||||
@ -36,11 +76,200 @@ const fmtMoney = (v: number) => {
|
||||
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
|
||||
// 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.
|
||||
/* ─────────────────────────────────────────────────────────────────────────────
|
||||
HERO — dải pipeline 4 giai đoạn
|
||||
───────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
// 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'
|
||||
|
||||
const STAT_TONE: Record<StatTone, { rail: string; chipBg: string; chipFg: string }> = {
|
||||
@ -72,24 +301,38 @@ function StatCard({
|
||||
<button
|
||||
onClick={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 }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">{label}</div>
|
||||
<span
|
||||
className="icon-chip h-9 w-9"
|
||||
className="icon-chip"
|
||||
style={{ ['--chip-bg' as string]: t.chipBg, ['--chip-fg' as string]: t.chipFg }}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
@ -113,20 +356,190 @@ export function UserDashboardPage() {
|
||||
(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 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 (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
title={`Xin chào, ${user?.fullName ?? user?.email ?? 'bạn'}`}
|
||||
description="Tổng quan công việc HĐ của bạn — click vào card để xem chi tiết."
|
||||
title="Tổng quan tất cả luồng duyệ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ý 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">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold text-slate-800">
|
||||
<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>
|
||||
<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>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||
<StatCard
|
||||
icon={Pencil}
|
||||
@ -168,142 +581,186 @@ export function UserDashboardPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<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">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold text-slate-800">
|
||||
<span className="icon-chip h-7 w-7">
|
||||
<FileText className="h-4 w-4" />
|
||||
</span>
|
||||
HĐ gần đây
|
||||
</h2>
|
||||
<Button variant="outline" onClick={() => navigate('/my-contracts')}>
|
||||
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 HĐ 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"
|
||||
{/* ── 2 danh sách gần đây (trước đây 2 khối dính sát nhau — nay đặt trong
|
||||
lưới có khoảng cách, xếp cạnh nhau ở màn rộng) ─────────────────── */}
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<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">
|
||||
<h2 className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className="icon-chip"
|
||||
style={{ ['--chip-bg' as string]: 'var(--color-brand-50)', ['--chip-fg' as string]: 'var(--color-brand-600)' }}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<FileText className="h-4 w-4" />
|
||||
</span>
|
||||
HĐ gần đây
|
||||
</h2>
|
||||
<Button variant="outline" onClick={() => navigate('/my-contracts')}>
|
||||
Xem tất cả
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{/* [S89 — anh Kiệt FDC] Phiếu Duyệt NCC gần đây — mỗi row có PhaseBadge (PE
|
||||
display-status) + PeFinalizeChip ("quy trình kết thúc, không lên CEO") +
|
||||
tên gói + mã phiếu. PE phase ≠ Contract phase nên dùng PE display-status pill. */}
|
||||
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<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">
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{recentPe.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" />
|
||||
{recent.isError && (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
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 HĐ 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 && (
|
||||
<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"
|
||||
{/* [S89 — anh Kiệt FDC] Phiếu Duyệt NCC gần đây — mỗi row có PeFinalizeChip
|
||||
("quy trình kết thúc, không lên CEO") + PE display-status pill + tên gói
|
||||
+ mã phiếu. PE phase ≠ Contract phase nên KHÔNG dùng PhaseBadge. */}
|
||||
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<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">
|
||||
<h2 className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className="icon-chip"
|
||||
style={{ ['--chip-bg' as string]: 'var(--color-violet-50)', ['--chip-fg' as string]: 'var(--color-violet-700)' }}
|
||||
>
|
||||
<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>
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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 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>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user