diff --git a/fe-user/src/pages/UserDashboardPage.tsx b/fe-user/src/pages/UserDashboardPage.tsx index 8b72838..50f38e5 100644 --- a/fe-user/src/pages/UserDashboardPage.tsx +++ b/fe-user/src/pages/UserDashboardPage.tsx @@ -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 = { + 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 ( +
+
+ + Giai đoạn {index} + + + + +
+ +

{title}

+
{stepRange}
+ +
{children}
+
+ ) +} + +// 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 ( +
+ + +
+ ) +} + +// 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à + ) +} + +// 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 ( + + ) +} + +// 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 ( +
+ + + Sắp triển khai + +

{description}

+ {hint &&

{hint}

} +
+ ) +} + +/* ───────────────────────────────────────────────────────────────────────────── + 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 = { @@ -72,24 +301,38 @@ function StatCard({ ) } +// Tiêu đề section dùng chung — vạch gradient brand + chữ đậm. +function SectionTitle({ children, note }: { children: React.ReactNode; note?: string }) { + return ( +
+

+ + {children} +

+ {note && {note}} +
+ ) +} + export function UserDashboardPage() { const navigate = useNavigate() const { user } = useAuth() @@ -113,20 +356,190 @@ export function UserDashboardPage() { (await api.get>('/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>('/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('/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 (
+ {/* ── HERO: pipeline 4 giai đoạn ───────────────────────────────────── */} +
+
+ + + +
+ {/* 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. */} +
+ Quy trình mua sắm → ký kết hợp đồng +
+

Toàn trình 4 giai đoạn · 21 bước

+
+
+ + {hasError && ( +
+ + + 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. + + +
+ )} + +
+ {/* GĐ1 — LIVE */} + +
+ navigate('/purchase-evaluations')} + /> + navigate(`/purchase-evaluations?phase=${PurchaseEvaluationPhase.DaDuyet}`)} + /> + navigate('/purchase-evaluations?pendingMe=1')} + /> +
+ navigate('/purchase-evaluations')} /> +
+ + + + {/* GĐ2 — sắp triển khai */} + + 0 + ? `${peApproved} phiếu Duyệt NCC đã duyệt` + : undefined + } + /> + + + + + {/* GĐ3 — LIVE */} + +
+ navigate('/my-contracts')} + /> + navigate('/inbox')} + /> + navigate('/inbox')} + /> +
+ navigate('/my-contracts')} /> +
+ + + + {/* GĐ4 — sắp triển khai */} + + + +
+
+ + {/* ── KPI cá nhân ──────────────────────────────────────────────────── */}
-

- - Của tôi -

+ Việc của tôi
-
-
-

- - - - HĐ gần đây -

- -
- - {recent.isLoading && ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
- ))} -
- )} - - {!recent.isLoading && (recent.data?.items?.length ?? 0) === 0 && ( -
- navigate('/contracts/new')}> - - Tạo HĐ mới - - } - /> -
- )} - -
    - {recent.data?.items?.map(c => ( -
  • - -
  • - ))} -
-
+ + + HĐ gần đây + + + - {/* [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. */} -
-
-

- - - - Phiếu Duyệt NCC gần đây -

- -
+ {recent.isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ )} - {recentPe.isLoading && ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
+ {recent.isError && ( +
+ void recent.refetch()}> + + Thử lại + + } + /> +
+ )} + + {!recent.isLoading && !recent.isError && (recent.data?.items?.length ?? 0) === 0 && ( +
+ navigate('/contracts/new')}> + + Tạo HĐ mới + + } + /> +
+ )} + +
    + {recent.data?.items?.map(c => ( +
  • + +
  • ))} -
- )} + +
- {!recentPe.isLoading && (recentPe.data?.items?.length ?? 0) === 0 && ( -
- -
- )} - -
    - {recentPe.data?.items?.map(p => ( -
  • - + + + {recentPe.isLoading && ( +
    + {Array.from({ length: 3 }).map((_, i) => ( +
    + ))} +
    + )} + + {recentPe.isError && ( +
    + void recentPe.refetch()}> + + Thử lại + + } + /> +
    + )} + + {!recentPe.isLoading && !recentPe.isError && (recentPe.data?.items?.length ?? 0) === 0 && ( +
    + +
    + )} + +
      + {recentPe.data?.items?.map(p => ( +
    • +
    - -
  • - ))} -
- + + + ))} + + +
) }