From a2bbcb91c37de3d79a61809fd903d918e7d9f105 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Wed, 29 Jul 2026 13:51:07 +0700 Subject: [PATCH] [CLAUDE] FE-User: dot-4 danh sach toan trinh - 7 FLAG reviewer fix + nhan 'Bang cung' (owner ve) - FLAG-1[M]: row HD mobile het ngo cut - mirror nhanh matchMedia cua MyContractsPage (desktop ?id= / mobile /contracts/:id) - FLAG-2[M]: JourneyDots nhan per-dot states; DOT_TEXT.none trung tinh; +state 'linked' (PeListItem.contractId THAT); contractDots: DangDongDau/DaPhatHanh cham dung GD4 (suy dien tuyen tinh CUNG module - hop le); aria chi ke cham CO thong tin - FLAG-3[L]: Pager dung page-state thay data.page (het race bam nhanh) - FLAG-4[L]: vung role=status giu ton tai khi 0 ket qua (screen-reader duoc bao) - FLAG-5[L]: 4-cham vao sub-line mobile (tang lien ket 2 khong bien mat tren dien thoai) - FLAG-6[L]: (ghi nhan - count tab vs search: de nguyen co chu thich o code, xu ky hon khi co UAT) - FLAG-7[L]: nut X goi clearSearch() - xoa NGAY khong cho debounce - Seeder: Hdc_ leaf + ' - Bang cung' (tree.Add + labelBackfill 7 entry; prod SQL suffix=7 cung ngay) Co-Authored-By: Claude Opus 5 --- fe-user/src/pages/UserDashboardPage.tsx | 1406 +++++++++++++---- .../Persistence/DbInitializer.cs | 10 +- 2 files changed, 1065 insertions(+), 351 deletions(-) diff --git a/fe-user/src/pages/UserDashboardPage.tsx b/fe-user/src/pages/UserDashboardPage.tsx index 4394178..ff64a73 100644 --- a/fe-user/src/pages/UserDashboardPage.tsx +++ b/fe-user/src/pages/UserDashboardPage.tsx @@ -5,58 +5,86 @@ // GĐ2 Kế hoạch ký kết HĐ (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 Hợp đồng cứng (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à). +// +// [S159 ĐỢT-4 — owner khoanh đỏ toàn bộ khối dưới hero] +// "bỏ hết mấy cái này đi, hiện theo list chi tiết đàng hoàng luôn. Nghĩ cách bố +// trí sao cho nó có danh sách phiếu mà User nhìn vào vẫn thấy đc tổng quan liên kết." +// ⇒ GỠ: section "Việc của tôi" (5 thẻ KPI) + "HĐ gần đây" + "Phiếu Duyệt NCC gần đây". +// ⇒ THAY bằng section "Danh sách phiếu toàn trình" = 2 TẦNG LIÊN KẾT: +// · tầng 1 — thanh tab 4 giai đoạn mang COUNT (GĐ1 = tổng phiếu, GĐ3 = tổng HĐ, +// GĐ2/GĐ4 badge "Sắp") ⇒ nhìn 1 phát biết khối lượng cả toàn trình. +// · tầng 2 — cột "Hành trình" trên TỪNG DÒNG (mini-strip 4 chấm) ⇒ mỗi phiếu tự +// kể nó đang đứng ở đâu trong 4 giai đoạn, không cần mở phiếu. +// + bảng chi tiết full-width theo tab, PHÂN TRANG THẬT + Ô TÌM KIẾM (2 tính năng +// này CÓ THẬT ở BE, xem "Nguồn số" bên dưới), bấm dòng → mở đúng phiếu/HĐ. +// Hero GIỮ NGUYÊN (owner không khoanh) — chỉ đổi nguồn nhãn sang const STAGES. // 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. +// Nguồn số — ĐÃ ĐỌC CONTROLLER, KHÔNG suy đoán param: +// · `GET /api/purchase-evaluations` (PurchaseEvaluationsController.cs:18-28) nhận +// page · pageSize · search · sortDesc · type · phase · projectId · approvalWorkflowId +// → PagedResult {items,total,page,pageSize,totalPages,hasNext,hasPrev}. +// `search` lọc MaPhieu ∨ TenGoiThau ∨ Project.Name ∨ Project.Code +// (PurchaseEvaluationFeatures.cs:624-632) — placeholder ghi đúng chừng đó. +// · `GET /api/contracts` (ContractsController.cs:16-24) nhận page · pageSize · search +// · sortDesc · phase · supplierId · projectId. `search` lọc MaHopDong ∨ TenHopDong +// ∨ Supplier.Name ∨ Project.Name (ContractFeatures.cs:309-316). +// ⇒ phân trang + tìm kiếm là THẬT ở cả 2 bảng, không phải cắt tính năng nào. +// · ⚠️ KHÔNG có query-param `pendingMe` ở BE — đó là ENDPOINT RIÊNG trả MẢNG phẳng +// (`/purchase-evaluations/inbox`, controller:30-35); `pendingMe=1` chỉ là URL param +// của TRANG list. `phase` thì đúng là query-param BE. +// · GĐ1 tổng phiếu / GĐ3 tổng HĐ = query `pageSize=1` chỉ lấy `.total` (rẻ nhất). +// · GĐ3 KPI (`chờ tôi duyệt` / `quá hạn`) = ['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. +// · Bảng chi tiết LAZY theo tab (`enabled: stage === n`) ⇒ GĐ2/GĐ4 bắn 0 request, +// và không bao giờ bắn cả 4 nguồn cùng lúc. // // ⚠️ 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' +// ⚠️ Contrast: brand-600 trên nền trắng chỉ 4.41:1 (hụt AA) ⇒ nền tab active dùng +// brand-700 (5.77:1 với chữ trắng) / teal-700 (5.82:1), badge trên tab active là +// pill TRẮNG chữ brand-800 (7.75:1) chứ không phải white/20 (3.85:1 — trượt AA). +import { useEffect, useRef, useState } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import { AlertTriangle, ArrowRight, - CheckCircle2, ChevronDown, + ChevronLeft, ChevronRight, ClipboardCheck, - Clock, - Coins, FileSignature, FileText, Hourglass, - Inbox, - Pencil, + ListChecks, + Plus, RefreshCw, + Search, + SearchX, Stamp, Workflow, + X, } from 'lucide-react' import { PageHeader } from '@/components/PageHeader' import { PhaseBadge } from '@/components/PhaseBadge' -import { SlaTimer } from '@/components/SlaTimer' import { EmptyState } from '@/components/EmptyState' import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' import { useAuth } from '@/contexts/AuthContext' import { api } from '@/lib/api' import { cn } from '@/lib/cn' import type { Paged } from '@/types/master' -import type { ContractListItem } from '@/types/contracts' +import { ContractPhase, type ContractListItem } from '@/types/contracts' import { ContractTypeLabel } from '@/types/forms' import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip' +import { PeUrgentChips } from '@/components/pe/PeUrgentChips' import { PurchaseEvaluationPhase, getPeDisplayStatus, + PeDisplayStatus, PeDisplayStatusColor, PeDisplayStatusLabel, type PeListItem, @@ -70,20 +98,63 @@ type MyDashboard = { draftsTotalValue: number } +const PAGE_SIZE = 10 + const fmtMoney = (v: number) => { if (v >= 1_000_000_000) return (v / 1_000_000_000).toFixed(1) + ' tỷ' if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + ' tr' return v.toLocaleString('vi-VN') } +const fmtDate = (iso?: string | null) => (iso ? new Date(iso).toLocaleDateString('vi-VN') : '—') + /* ───────────────────────────────────────────────────────────────────────────── - HERO — dải pipeline 4 giai đoạn + 4 GIAI ĐOẠN — SINGLE SOURCE cho CẢ hero LẪN thanh tab. + Nhãn 4 GĐ đã bị sửa qua 3 đợt; hero và tab mà chép tay 2 nơi thì drift lần 4 là + chắc chắn. Khai 1 chỗ ⇒ tab echo đúng hero DO CẤU TRÚC, không do cẩn thận. + Tên chốt khớp `components/pe/PePipelineStrip.tsx` (owner vẽ tab lên ảnh phiếu). + ───────────────────────────────────────────────────────────────────────────── */ + +type StageTone = 'brand' | 'teal' + +type StageMeta = { + n: number + title: string + stepRange: string + icon: React.ComponentType<{ className?: string }> + /** undefined = giai đoạn chưa triển khai (khung mờ, không có số). */ + tone?: StageTone + /** Câu mô tả cho giai đoạn chưa triển khai (hero + panel dùng chung). */ + soonDesc?: string +} + +const STAGES: StageMeta[] = [ + { n: 1, title: 'Duyệt NCC', stepRange: 'Bước 1 → 6 · CEO chốt', icon: ClipboardCheck, tone: 'brand' }, + { + n: 2, + title: 'Kế hoạch ký kết HĐ', + stepRange: 'Bước 7 → 12', + icon: FileSignature, + soonDesc: 'Phiếu nối tiếp từ phiếu Duyệt NCC đã duyệt — chốt giá trị ký kết.', + }, + { n: 3, title: 'Duyệt hợp đồng', stepRange: 'Bước 13 → 18', icon: FileText, tone: 'teal' }, + { + n: 4, + title: 'Hợp đồng cứng', + stepRange: 'Bước 19 → 21', + icon: Stamp, + soonDesc: 'Tải lên bản hợp đồng đã ký cứng.', + }, +] + +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. -type StageTone = 'brand' | 'teal' - const STAGE_TONE: Record - tone?: StageTone - children: React.ReactNode -}) { +// 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 (
- Giai đoạn {index} + Giai đoạn {n} {label} {loading ? ( - + ) : error ? ( — @@ -253,7 +311,7 @@ function StageLink({ label, tone, onClick }: { label: string; tone: StageTone; o } // 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 }) { +function StageSoon({ description, hint }: { description?: string; hint?: string }) { return (
@@ -267,96 +325,784 @@ function StageSoon({ description, hint }: { description: string; hint?: string } } /* ───────────────────────────────────────────────────────────────────────────── - KPI cá nhân (giữ nguyên từ bản trước — chỉ tinh chỉnh tương phản) + 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ờ + + tooltip tên giai đoạn ⇒ user đọc 1 dòng là biết phiếu đang đứng đâu trong 21 bước. + KHÔNG suy diễn "có HĐ ⇒ GĐ1/GĐ2 xong": cầu nối per-phiếu giữa các giai đoạn CHƯA + có (PePipelineStrip.tsx:8-9 nói rõ) — bịa ra là nói dối user. ───────────────────────────────────────────────────────────────────────────── */ -type StatTone = 'default' | 'teal' | 'warn' | 'good' | 'danger' | 'violet' +type DotState = 'done' | 'running' | 'returned' | 'draft' | 'stopped' | 'linked' | 'none' -const STAT_TONE: Record = { - default: { rail: 'var(--color-brand-500)', chipBg: 'var(--color-brand-50)', chipFg: 'var(--color-brand-600)' }, - teal: { rail: 'var(--color-teal-500)', chipBg: 'var(--color-teal-50)', chipFg: 'var(--color-teal-700)' }, - warn: { rail: 'var(--color-amberx-500)', chipBg: 'var(--color-amberx-50)', chipFg: 'var(--color-amberx-700)' }, - good: { rail: 'var(--color-greenx-500)', chipBg: 'var(--color-greenx-50)', chipFg: 'var(--color-greenx-700)' }, - danger: { rail: 'var(--color-accent-500)', chipBg: '#fdecec', chipFg: 'var(--color-accent-600)' }, - violet: { rail: 'var(--color-violet-500)', chipBg: 'var(--color-violet-50)', chipFg: 'var(--color-violet-700)' }, +const DOT_TEXT: Record = { + done: 'đã duyệt xong', + running: 'đang chạy quy trình duyệt', + returned: 'bị trả lại, cần chỉnh sửa', + draft: 'còn là bản nháp', + stopped: 'đã từ chối — dừng tại đây', + // [FLAG-2 reviewer đợt-4] 'chưa tới' là lời KHẲNG ĐỊNH sai với chấm PHÍA TRƯỚC + // giai đoạn hiện tại (HĐ ở GĐ3 thì GĐ1/GĐ2 là quá khứ, không phải "chưa tới"). + none: 'chưa có thông tin ở giai đoạn này', + linked: 'đã sinh hợp đồng — theo dõi ở giai đoạn 3', } -function StatCard({ - icon: Icon, - label, - value, - hint, - tone = 'default', - onClick, +function dotVisual(state: DotState, tone: StageTone): { className: string; style?: React.CSSProperties } { + const solid = tone === 'brand' ? 'var(--color-brand-600)' : 'var(--color-teal-600)' + const mid = tone === 'brand' ? 'var(--color-brand-500)' : 'var(--color-teal-500)' + switch (state) { + case 'done': + return { className: 'border-transparent', style: { background: solid } } + case 'running': + // Nửa chấm = đang đi giữa chừng. Gradient cứng 50/50, không phải mờ dần. + return { className: '', style: { background: `linear-gradient(90deg, ${mid} 50%, #ffffff 50%)`, borderColor: mid } } + case 'returned': + return { + className: '', + style: { + background: 'linear-gradient(90deg, var(--color-amberx-500) 50%, #ffffff 50%)', + borderColor: 'var(--color-amberx-500)', + }, + } + case 'draft': + return { className: 'border-slate-300 bg-white' } + case 'stopped': + return { className: 'border-transparent', style: { background: 'var(--color-accent-500)' } } + case 'linked': + // Viền teal đậm, ruột trắng — "có vật ở GĐ3" nhưng KHÔNG khẳng định trạng thái của nó. + return { className: 'bg-white', style: { borderColor: 'var(--color-teal-600)', borderWidth: 2 } } + default: + return { className: 'border-transparent bg-slate-200' } + } +} + +// [FLAG-2 đợt-4] Nhận TRẠNG THÁI TỪNG CHẤM — aria chỉ kể các chấm CÓ thông tin, +// không khẳng định hộ các chấm không biết. +function JourneyDots({ dots, tone }: { dots: DotState[]; tone: StageTone }) { + const known = dots + .map((d, i) => (d === 'none' ? null : `giai đoạn ${i + 1} ${STAGES[i].title} — ${DOT_TEXT[d]}`)) + .filter((x): x is string => x !== null) + return ( + 0 + ? `Hành trình toàn trình: ${known.join('; ')}.` + : 'Hành trình toàn trình: chưa có thông tin.' + } + className="inline-flex items-center" + > + {STAGES.map((s, i) => { + const st: DotState = dots[i] ?? 'none' + const v = dotVisual(st, tone) + return ( + + + {i < STAGES.length - 1 && } + + ) + })} + + ) +} + +const peDot = (phase: number): DotState => { + const s = getPeDisplayStatus(phase) + if (s === PeDisplayStatus.DaDuyet) return 'done' + if (s === PeDisplayStatus.TuChoi) return 'stopped' + if (s === PeDisplayStatus.TraLai) return 'returned' + if (s === PeDisplayStatus.Nhap) return 'draft' + return 'running' +} + +const contractDot = (phase: number): DotState => { + if (phase === ContractPhase.TuChoi) return 'stopped' + if (phase === ContractPhase.TraLai) return 'returned' + if (phase === ContractPhase.DangChon || phase === ContractPhase.DangSoanThao) return 'draft' + return 'running' +} + +// [FLAG-2 ca-3 đợt-4] DangDongDau(b.19-20)/DaPhatHanh(b.21) chính là phần việc GĐ4 +// "Hợp đồng cứng" theo định nghĩa trang này ⇒ chấm 4 nhận trạng thái, chấm 3 = done +// (suy diễn TIẾN-TRÌNH trong CÙNG module HĐ — phase tuyến tính — hợp lệ; GĐ1/GĐ2 +// KHÔNG biết ⇒ trung tính). +const contractDots = (phase: number): DotState[] => { + if (phase === ContractPhase.DaPhatHanh) return ['none', 'none', 'done', 'done'] + if (phase === ContractPhase.DangDongDau) return ['none', 'none', 'done', 'running'] + return ['none', 'none', contractDot(phase), 'none'] +} + +// [FLAG-2 ca-2] Chấm 3 của dòng phiếu dùng field THẬT `contractId` trong payload +// (types/purchaseEvaluation.ts:126): chỉ nói "đã sinh HĐ", không đoán hơn. +const peDots = (phase: number, contractId: string | null): DotState[] => [ + peDot(phase), + 'none', + contractId ? 'linked' : 'none', + 'none', +] + +/* ───────────────────────────────────────────────────────────────────────────── + TẦNG LIÊN KẾT 1 — thanh tab 4 giai đoạn (segmented), mang COUNT + ───────────────────────────────────────────────────────────────────────────── */ + +const TAB_TONE: Record<'brand' | 'teal' | 'soon', { + active: string; idle: string; chip: string; badge: string +}> = { + brand: { + active: 'border-brand-700 bg-brand-700 text-white shadow-sm shadow-brand-700/25', + idle: 'border-slate-200 bg-white text-slate-700 hover:border-brand-300 hover:bg-brand-50', + chip: 'bg-brand-50 text-brand-600', + badge: 'bg-white text-brand-800', + }, + teal: { + active: 'border-teal-700 bg-teal-700 text-white shadow-sm shadow-teal-700/25', + idle: 'border-slate-200 bg-white text-slate-700 hover:border-teal-500 hover:bg-teal-50', + chip: 'bg-teal-50 text-teal-700', + badge: 'bg-white text-teal-700', + }, + soon: { + active: 'border-slate-600 bg-slate-600 text-white shadow-sm', + idle: 'border-dashed border-slate-300 bg-white text-slate-600 hover:border-slate-400 hover:bg-slate-100', + chip: 'bg-slate-100 text-slate-500', + badge: 'bg-white text-slate-600', + }, +} + +function StageTab({ + stage, + active, + count, + loading, + onSelect, + onKeyDown, + btnRef, }: { - icon: React.ComponentType<{ className?: string }> - label: string - value: React.ReactNode - hint?: string - tone?: StatTone - onClick?: () => void + stage: StageMeta + active: boolean + count?: number + loading?: boolean + onSelect: () => void + onKeyDown: (e: React.KeyboardEvent) => void + btnRef: (el: HTMLButtonElement | null) => void }) { - const t = STAT_TONE[tone] + const { n, title, icon: Icon, tone } = stage + const t = TAB_TONE[tone ?? 'soon'] + const soon = !tone return ( ) } -// Tiêu đề section dùng chung — vạch gradient brand + chữ đậm. -function SectionTitle({ children, note }: { children: React.ReactNode; note?: string }) { +/* ───────────────────────────────────────────────────────────────────────────── + Mảnh dùng chung của panel: toolbar tìm kiếm · skeleton · phân trang + ───────────────────────────────────────────────────────────────────────────── */ + +function ListToolbar({ + value, + onChange, + onClear, + placeholder, + right, +}: { + value: string + onChange: (v: string) => void + // [FLAG-7 đợt-4] X phải xoá NGAY (cả input lẫn từ khoá đã áp) — onChange('') chỉ đổi + // input rồi chờ debounce 300ms ⇒ hai nút "xoá tìm kiếm" phản hồi khác nhau. + onClear: () => void + placeholder: string + right?: React.ReactNode +}) { return ( -
-

- - {children} -

- {note && {note}} +
+
+ + onChange(e.target.value)} + /> + {value !== '' && ( + + )} +
+ {right &&
{right}
}
) } +// Skeleton dùng 1 ô colSpan — KHÔNG dựng cell theo từng cột, tránh lệch với các +// cột bị ẩn theo breakpoint (hidden md/lg/xl:table-cell). +function TableSkeleton({ cols, rows = 6 }: { cols: number; rows?: number }) { + return ( + + {Array.from({ length: rows }).map((_, i) => ( + + + + + + + + + + ))} + + ) +} + +function Pager({ + data, + unit, + page, + onPage, +}: { + data?: Paged + unit: string + // [FLAG-3 đợt-4] trang ĐANG CHỌN (state) — nút ± tính từ đây, không từ data.page + // (data là ảnh chụp request đã về; bấm nhanh 2 lần khi request đang bay ⇒ chỉ đi 1 trang). + page: number + onPage: (p: number) => void +}) { + if (!data) return null + if (data.total === 0) + // [FLAG-4 đợt-4] giữ vùng thông báo cho screen-reader khi kết quả về 0 + // (aria-live bị gỡ khỏi cây thì không ai được báo). + return ( +

+ Không tìm thấy {unit} nào khớp. +

+ ) + const from = (data.page - 1) * data.pageSize + 1 + const to = Math.min(data.page * data.pageSize, data.total) + return ( +
+

+ Hiển thị {from}–{to} trong{' '} + {data.total} {unit} +

+
+ + + Trang {data.page}/{Math.max(data.totalPages, 1)} + + +
+
+ ) +} + +const TH = 'px-3 py-2 font-bold sm:px-4' +const THEAD_ROW = 'border-b border-slate-200 bg-slate-50/80 text-[10px] uppercase tracking-wide text-slate-500' + +/* ───────────────────────────────────────────────────────────────────────────── + BẢNG GĐ1 — phiếu Duyệt NCC + ───────────────────────────────────────────────────────────────────────────── */ + +const PE_COLS = 7 + +function PeTable({ + items, + isLoading, + isError, + isFetching, + hasSearch, + onRetry, + onOpen, + onCreate, + onClearSearch, +}: { + items?: PeListItem[] + isLoading: boolean + isError: boolean + isFetching: boolean + hasSearch: boolean + onRetry: () => void + onOpen: (id: string) => void + onCreate: () => void + onClearSearch: () => void +}) { + return ( +
+ + + + + + + + + + + + + + + {isLoading ? ( + + ) : ( + + {isError && ( + + + + )} + + {!isError && (items?.length ?? 0) === 0 && ( + + + + )} + + {!isError && + items?.map(p => ( + onOpen(p.id)} + className="cursor-pointer transition hover:bg-brand-50/60" + > + + + + + + + + + + + + + + + ))} + + )} +
+ Danh sách phiếu Duyệt NCC — giai đoạn 1 của toàn trình. Cột Hành trình cho biết mỗi phiếu đang + đứng ở giai đoạn nào. +
Mã phiếuGói thầuDự ánNgười soạnNgàyTrạng tháiHành trình
+ + + Thử lại + + } + /> +
+ + + Xoá tìm kiếm + + ) : ( + + ) + } + /> +
+ + +
+ {p.tenGoiThau} + +
+ {p.workItemName && ( +
+ {p.workItemName} +
+ )} + {/* Mobile: gom Dự án + Ngày vào đây vì 2 cột đó ẩn dưới lg/md. */} +
+ {p.projectName} · {fmtDate(p.updatedAt ?? p.createdAt)} +
+ {/* [FLAG-5 đợt-4] cột Hành trình ẩn + + +
+
{p.projectName}
+ {p.projectCode && ( +
+ {p.projectCode} +
+ )} +
+
+ {p.drafterName ?? '—'} +
+ {p.departmentName && ( +
+ {p.departmentName} +
+ )} +
+ {fmtDate(p.updatedAt ?? p.createdAt)} + +
+ + {PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]} + + +
+
+ +
+
+ ) +} + +/* ───────────────────────────────────────────────────────────────────────────── + BẢNG GĐ3 — hợp đồng + ───────────────────────────────────────────────────────────────────────────── */ + +const CT_COLS = 7 + +function ContractTable({ + items, + isLoading, + isError, + isFetching, + hasSearch, + onRetry, + onOpen, + onCreate, + onClearSearch, +}: { + items?: ContractListItem[] + isLoading: boolean + isError: boolean + isFetching: boolean + hasSearch: boolean + onRetry: () => void + onOpen: (id: string) => void + onCreate: () => void + onClearSearch: () => void +}) { + return ( +
+ + + + + + + + + + + + + + + {isLoading ? ( + + ) : ( + + {isError && ( + + + + )} + + {!isError && (items?.length ?? 0) === 0 && ( + + + + )} + + {!isError && + items?.map(c => ( + onOpen(c.id)} + className="cursor-pointer transition hover:bg-teal-50/60" + > + + + + + + + + + + + + + + + ))} + + )} +
+ Danh sách hợp đồng — giai đoạn 3 của toàn trình. Cột Hành trình cho biết mỗi hợp đồng đang đứng ở + giai đoạn nào. +
Mã HĐTên hợp đồngLoạiGiá trịNgàyTrạng tháiHành trình
+ + + Thử lại + + } + /> +
+ + + Xoá tìm kiếm + + ) : ( + + ) + } + /> +
+ + +
+ {c.tenHopDong ?? '(chưa đặt tên)'} +
+
+ {c.supplierName} · {c.projectName} +
+ {/* Mobile: 3 cột dưới đây ẩn ⇒ gom giá trị + ngày vào dòng phụ. */} +
+ {fmtMoney(c.giaTri)} · {fmtDate(c.createdAt)} +
+
+ {ContractTypeLabel[c.type] ?? '—'} + + {fmtMoney(c.giaTri)} + + {fmtDate(c.createdAt)} + + + + +
+
+ ) +} + +/* ───────────────────────────────────────────────────────────────────────────── + Panel GĐ2 / GĐ4 — "Sắp triển khai" NGẮN (2-3 dòng), không lặp lại nguyên + trang ComingSoon; nút dẫn sang `/coming-soon?stage=N` (route THẬT, đọc `stage`). + ───────────────────────────────────────────────────────────────────────────── */ + +function SoonPanel({ stage, onOpenRoadmap }: { stage: StageMeta; onOpenRoadmap: () => void }) { + const Icon = stage.icon + return ( +
+
+ + + + + + Sắp triển khai + +

+ Giai đoạn {stage.n} · {stage.title} +

+

{stage.soonDesc}

+

+ Phân hệ đang được xây dựng — danh sách phiếu của giai đoạn này sẽ hiện ngay tại đây khi chạy. +

+ +
+
+ ) +} + +/* ───────────────────────────────────────────────────────────────────────────── + TRANG + ───────────────────────────────────────────────────────────────────────────── */ + export function UserDashboardPage() { const navigate = useNavigate() const { user } = useAuth() + /* ── 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('/reports/my-dashboard')).data, }) - const recent = useQuery({ - queryKey: ['my-contracts-recent'], + const peTotalQuery = useQuery({ + queryKey: ['pipeline-pe-total'], queryFn: async () => - (await api.get>('/contracts', { params: { page: 1, pageSize: 5 } })).data, + (await api.get>('/purchase-evaluations', { params: { page: 1, pageSize: 1 } })).data + .total, }) - // [S89 — anh Kiệt FDC] 5 Phiếu Duyệt NCC gần đây, kèm chip "Quy trình kết thúc" - // (PeFinalizeChip) để user nhìn nhanh phiếu nào duyệt-đến-đây-là-xong không lên CEO. - const recentPe = useQuery({ - queryKey: ['my-pe-recent'], + const contractTotalQuery = useQuery({ + queryKey: ['pipeline-contract-total'], queryFn: async () => - (await api.get>('/purchase-evaluations', { params: { page: 1, pageSize: 5 } })).data, + (await api.get>('/contracts', { params: { page: 1, pageSize: 1 } })).data.total, }) - // [S159] +1 req — số phiếu Duyệt NCC ĐÃ DUYỆT (phase=7). pageSize=1 vì chỉ cần `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 () => @@ -375,23 +1121,101 @@ export function UserDashboardPage() { const peInboxQuery = useQuery({ queryKey: ['pe-inbox'], queryFn: async () => (await api.get('/purchase-evaluations/inbox')).data, - select: (d) => d.length, + select: d => d.length, }) const s = stats.data const peApproved = peApprovedQuery.data const hasError = - stats.isError || recent.isError || recentPe.isError || peApprovedQuery.isError || peInboxQuery.isError + stats.isError || + contractTotalQuery.isError || + peTotalQuery.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 (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) + const [page, setPage] = useState(1) + const [searchInput, setSearchInput] = useState('') + const [search, setSearch] = useState('') + const tabRefs = useRef<(HTMLButtonElement | null)[]>([]) + + // Gõ tới đâu bắn request tới đó = phí. Chờ 300ms im mới commit, và chỉ khi giá + // trị THỰC SỰ đổi (gõ rồi xoá về như cũ thì không reset trang đang xem). + useEffect(() => { + const v = searchInput.trim() + if (v === search) return + const t = setTimeout(() => { + setSearch(v) + setPage(1) + }, 300) + return () => clearTimeout(t) + }, [searchInput, search]) + + const selectStage = (n: number) => { + if (n === stage) return + setStage(n) + setPage(1) + setSearchInput('') + setSearch('') + } + + // Bàn phím trong tablist theo WAI-ARIA: ←/→ cuộn vòng, Home/End nhảy biên. + const onTabKeyDown = (e: React.KeyboardEvent, i: number) => { + const last = STAGES.length - 1 + let next = -1 + if (e.key === 'ArrowRight') next = i === last ? 0 : i + 1 + else if (e.key === 'ArrowLeft') next = i === 0 ? last : i - 1 + else if (e.key === 'Home') next = 0 + else if (e.key === 'End') next = last + if (next < 0) return + e.preventDefault() + selectStage(STAGES[next].n) + tabRefs.current[next]?.focus() + } + + const clearSearch = () => { + setSearchInput('') + setSearch('') + setPage(1) + } + + const peList = useQuery({ + queryKey: ['dash-stage-pe', { page, pageSize: PAGE_SIZE, search }], + queryFn: async () => + ( + await api.get>('/purchase-evaluations', { + params: { page, pageSize: PAGE_SIZE, search: search || undefined }, + }) + ).data, + enabled: stage === 1, + placeholderData: keepPreviousData, + }) + + const contractList = useQuery({ + queryKey: ['dash-stage-contracts', { page, pageSize: PAGE_SIZE, search }], + queryFn: async () => + ( + await api.get>('/contracts', { + params: { page, pageSize: PAGE_SIZE, search: search || undefined }, + }) + ).data, + enabled: stage === 3, + placeholderData: keepPreviousData, + }) + + const activeStage = stageOf(stage) + return (
{/* GĐ1 — LIVE */} - +
navigate('/purchase-evaluations')} /> navigate(`/purchase-evaluations?phase=${PurchaseEvaluationPhase.DaDuyet}`)} /> {/* GĐ2 — sắp triển khai */} - + 0 ? `${peApproved} phiếu Duyệt NCC đã duyệt` @@ -487,25 +1305,19 @@ export function UserDashboardPage() { {/* GĐ3 — LIVE */} - +
navigate('/my-contracts')} /> navigate('/inbox')} /> {/* GĐ4 — sắp triển khai */} - - + +
- {/* ── KPI cá nhân ──────────────────────────────────────────────────── */} -
- Việc của tôi -
- navigate('/my-contracts')} - /> - navigate('/inbox')} - /> - navigate('/inbox')} - /> - navigate('/inbox')} - /> - navigate('/my-contracts')} - /> + {/* ── DANH SÁCH PHIẾU TOÀN TRÌNH ───────────────────────────────────── */} +
+
+ + + +
+
+ Danh sách chi tiết +
+

Danh sách phiếu toàn trình

+
+

+ Chọn giai đoạn để xem danh sách của giai đoạn đó. +
+ Cột Hành trình cho biết từng phiếu đang đứng + ở đâu. +

+
+ + {/* Tầng liên kết 1 — thanh tab 4 giai đoạn mang count */} +
+
+ {STAGES.map((st, i) => ( + selectStage(st.n)} + onKeyDown={e => onTabKeyDown(e, i)} + btnRef={el => { + tabRefs.current[i] = el + }} + /> + ))} +
+
+ + {/* Tầng liên kết 2 — bảng chi tiết theo tab đang chọn */} +
+ {stage === 1 && ( + <> + navigate('/purchase-evaluations')}> + Mở màn Duyệt NCC + + + } + /> + void peList.refetch()} + onOpen={id => navigate(`/purchase-evaluations/${id}`)} + onCreate={() => navigate('/purchase-evaluations/new')} + onClearSearch={clearSearch} + /> + + + )} + + {stage === 3 && ( + <> + navigate('/my-contracts')}> + Mở màn Hợp đồng + + + } + /> + void contractList.refetch()} + onOpen={id => + // [FLAG-1 đợt-4] MyContractsPage render detail ở panel `hidden lg:block` + // ⇒ mobile mở ?id= là ngõ cụt. Mirror ĐÚNG nhánh trang đích (:62-67). + window.matchMedia('(min-width: 1024px)').matches + ? navigate(`/my-contracts?id=${id}`) + : navigate(`/contracts/${id}`) + } + onCreate={() => navigate('/contracts/new')} + onClearSearch={clearSearch} + /> + + + )} + + {(stage === 2 || stage === 4) && ( + navigate(`/coming-soon?stage=${activeStage.n}`)} + /> + )}
- - {/* ── 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) ─────────────────── */} -
-
-
-

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

- -
- - {recent.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 => ( -
  • - -
  • - ))} -
-
- - {/* [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. */} -
-
-

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

- -
- - {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 => ( -
  • - -
  • - ))} -
-
-
) } diff --git a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs index f0466de..43b1fa2 100644 --- a/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs +++ b/src/Backend/SolutionErp.Infrastructure/Persistence/DbInitializer.cs @@ -1861,7 +1861,7 @@ public static class DbInitializer var label = typeLabels.GetValueOrDefault(code, code); // [S159-đợt3] GĐ4 skeleton — 1 leaf/loại HĐ dưới root Hợp đồng cứng, tái dùng // đúng nhãn đã đánh số 01.-07. (leaf → /coming-soon?stage=4). - tree.Add(("Hdc_" + code, label, MenuKeys.HopDongCung, hdcOrder++, "FileText")); + tree.Add(("Hdc_" + code, label + " - Bảng cứng", MenuKeys.HopDongCung, hdcOrder++, "FileText")); // [S159-đợt4] owner vẽ: "01. HĐ Thầu phụ - Bảng cứng" tree.Add((MenuKeys.ContractTypeGroup(code), label, MenuKeys.Contracts, order++, "FileText")); tree.Add((MenuKeys.ContractTypeList(code), "Danh sách", MenuKeys.ContractTypeGroup(code), order++, "List")); tree.Add((MenuKeys.ContractTypeCreate(code), "Thao tác", MenuKeys.ContractTypeGroup(code), order++, "Plus")); @@ -1958,6 +1958,14 @@ public static class DbInitializer // [S159] 2 root rename theo 4-GĐ (prod đã SQL cùng ngày ⇒ NO-OP trên prod). [MenuKeys.PurchaseEvaluations] = "Duyệt NCC", [MenuKeys.Contracts] = "Duyệt hợp đồng", + // [S159-đợt4] 7 leaf Hdc_ thêm hậu tố " - Bảng cứng" (owner vẽ; prod SQL cùng ngày). + ["Hdc_ThauPhu"] = "01. HĐ Thầu phụ - Bảng cứng", + ["Hdc_GiaoKhoan"] = "02. HĐ Giao khoán - Bảng cứng", + ["Hdc_NhaCungCap"] = "03. HĐ Nhà cung cấp - Bảng cứng", + ["Hdc_DichVu"] = "04. HĐ Dịch vụ - Bảng cứng", + ["Hdc_MuaBan"] = "05. HĐ Mua bán - Bảng cứng", + ["Hdc_NguyenTacNcc"] = "06. HĐ Nguyên tắc NCC - Bảng cứng", + ["Hdc_NguyenTacDv"] = "07. HĐ Nguyên tắc Dịch vụ - Bảng cứng", [MenuKeys.ContractTypeGroup("ThauPhu")] = "01. HĐ Thầu phụ", [MenuKeys.ContractTypeGroup("GiaoKhoan")] = "02. HĐ Giao khoán", [MenuKeys.ContractTypeGroup("NhaCungCap")] = "03. HĐ Nhà cung cấp",