[CLAUDE] Contract+FE-User: dot-5 menu 6-leaf y-chang-Duyet-NCC + GET /contracts/deleted + bo hero + "Tong quan quy trinh"
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m38s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m38s
- BE: ListDeletedContractsQuery (IgnoreQueryFilters + Where(IsDeleted) + IDOR mirror; LUAT thay so-dem theo reviewer FLAG-10) + GET /contracts/deleted [Contracts.Read] - Seeder: Ct x7 +3 leaf (WfView/Approved/Deleted) + labelBackfill "Dang duyet" x7 + Khkk 6 leaf + Dashboard -> "Tong quan quy trinh" + reviewKeys +23 key - FE user: matrix +type=3 (HD chung 1 bo V2) + route /contracts/workflow-matrix + Ct regex +3 action + MyContractsPage (phase/deleted + banner + an nut Tao + FLAG-2 guard selectContract khi deleted) + MenuLeaf depth-0 UPPERCASE + UserDashboardPage BO HERO (owner: "bo cai tren di, cai duoi OK") + title "Tong quan quy trinh" - fe-admin: staticMap +2 Khkk (FLAG-1); leaf Ct_ moi van an theo isAdminHidden (da kiem CLEAN) - reviewer PASS_WITH_FLAGS 10 FLAG (0H/4M/6L): fix 4M, park 6L co ghi (FLAG-4 dinh nghia "Da duyet" HD -> cho owner) - build fe x2 + BE PASS; test 562 PASS Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@ -61,6 +61,8 @@ function App() {
|
||||
<Route path="/my-contracts" element={<MyContractsPage />} />
|
||||
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
|
||||
<Route path="/purchase-evaluations/workflow-matrix" element={<WorkflowMatrixViewPage />} />
|
||||
{/* [S159-đợt5] alias cho leaf "Luồng duyệt" của 7 nhóm HĐ (?type=3) */}
|
||||
<Route path="/contracts/workflow-matrix" element={<WorkflowMatrixViewPage />} />
|
||||
<Route path="/purchase-evaluations/workspace" element={<PurchaseEvaluationWorkspacePage />} />
|
||||
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
|
||||
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
|
||||
|
||||
@ -75,6 +75,9 @@ function resolvePath(key: string): string | null {
|
||||
// LESSON gotcha #50: key DB mới PHẢI có route ở đây, không thì sidebar drop SILENT.
|
||||
KeHoachKyKet: '/dashboard',
|
||||
HopDongCung: '/dashboard',
|
||||
// [S159-đợt5] 2 leaf mới của skeleton GĐ2 (đủ 6 y chang Duyệt NCC)
|
||||
Khkk_WfView: '/coming-soon?stage=2&m=wfview',
|
||||
Khkk_Deleted: '/coming-soon?stage=2&m=deleted',
|
||||
// [Plan CA Hotfix 1 S29 2026-05-22] 4 master + 4 catalog leaf moved từ
|
||||
// fe-admin → fe-user. resolvePath PHẢI có route mapping nếu không
|
||||
// MenuLeaf line 238 `if (!path) return null` → sidebar drop silent.
|
||||
@ -124,7 +127,9 @@ function resolvePath(key: string): string | null {
|
||||
}
|
||||
if (staticMap[key]) return staticMap[key]
|
||||
|
||||
const match = key.match(/^Ct_([^_]+)_(List|Create|Pending)$/)
|
||||
// [S159-đợt5 owner "y chang Duyệt NCC"] +WfView/Approved/Deleted. WfView cả 7 loại
|
||||
// đều ?type=3 (HĐ chung 1 bộ workflow V2) — thêm &ct=N để active-state riêng từng leaf.
|
||||
const match = key.match(/^Ct_([^_]+)_(List|Create|Pending|WfView|Approved|Deleted)$/)
|
||||
if (match) {
|
||||
const [, code, action] = match
|
||||
const typeInt = TYPE_CODE_TO_INT[code]
|
||||
@ -132,6 +137,9 @@ function resolvePath(key: string): string | null {
|
||||
if (action === 'List') return `/my-contracts?type=${typeInt}`
|
||||
if (action === 'Create') return `/contracts/new?type=${typeInt}`
|
||||
if (action === 'Pending') return `/inbox?type=${typeInt}`
|
||||
if (action === 'WfView') return `/contracts/workflow-matrix?type=3&ct=${typeInt}`
|
||||
if (action === 'Approved') return `/my-contracts?type=${typeInt}&phase=9`
|
||||
if (action === 'Deleted') return `/my-contracts?type=${typeInt}&deleted=1`
|
||||
}
|
||||
|
||||
// Pe_<Code>_<Action> cho module Duyệt NCC (user side)
|
||||
@ -343,7 +351,15 @@ function MenuLeaf({ node, depth }: { node: MenuNode; depth: number }) {
|
||||
// Density-first: active leaf gets a brand left-rail + tint (crisp
|
||||
// selected affordance, NAMGROUP). Inactive stays quiet slate.
|
||||
'relative block rounded-md leading-snug transition-colors',
|
||||
isDeep ? 'px-3 py-1 text-[11px]' : 'px-3 py-1.5 text-[12px] font-medium',
|
||||
// [S159-đợt5 owner] leaf cấp-0 render qua MenuLeaf ("Tổng quan quy trình") viết
|
||||
// HOA đồng bộ group header cấp-0 (khuôn MenuGroup :260). 🔴 KHAI ĐÚNG PHẠM VI
|
||||
// (reviewer FLAG-3): "Hộp thư" KHÔNG đi qua đây — nó là USER_FIXED_TOP render
|
||||
// bằng StaticLeaf (lớp CSS riêng, vẫn chữ thường). Đồng bộ nốt = quyết owner.
|
||||
depth === 0
|
||||
? 'px-3 py-2 text-[11px] font-semibold uppercase tracking-wide'
|
||||
: isDeep
|
||||
? 'px-3 py-1 text-[11px]'
|
||||
: 'px-3 py-1.5 text-[12px] font-medium',
|
||||
isActive
|
||||
? 'bg-brand-50 font-semibold text-brand-700 before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full before:bg-brand-600'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
||||
|
||||
@ -52,7 +52,6 @@ import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
@ -65,7 +64,6 @@ import {
|
||||
Search,
|
||||
SearchX,
|
||||
Stamp,
|
||||
Workflow,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
@ -82,7 +80,6 @@ import { ContractTypeLabel } from '@/types/forms'
|
||||
import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip'
|
||||
import { PeUrgentChips } from '@/components/pe/PeUrgentChips'
|
||||
import {
|
||||
PurchaseEvaluationPhase,
|
||||
getPeDisplayStatus,
|
||||
PeDisplayStatus,
|
||||
PeDisplayStatusColor,
|
||||
@ -90,14 +87,6 @@ import {
|
||||
type PeListItem,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
|
||||
type MyDashboard = {
|
||||
draftsInProgress: number
|
||||
pendingMyApproval: number
|
||||
dueSoon: number
|
||||
overdue: number
|
||||
draftsTotalValue: number
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
const fmtMoney = (v: number) => {
|
||||
@ -153,177 +142,6 @@ const stageOf = (n: number) => STAGES.find(s => s.n === n) ?? STAGES[0]
|
||||
HERO — dải pipeline 4 giai đoạn (GIỮ NGUYÊN, owner đã duyệt)
|
||||
───────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
// Giai đoạn LIVE mang accent (brand / teal); giai đoạn "sắp triển khai" = khung
|
||||
// nét đứt xám trung tính — MỜ nhưng KHÔNG đỏ/vàng, để không đọc nhầm thành lỗi.
|
||||
const STAGE_TONE: Record<StageTone, {
|
||||
rail: string; chipBg: string; chipFg: string
|
||||
badge: string; head: string; row: string; num: string; link: string
|
||||
}> = {
|
||||
brand: {
|
||||
rail: 'var(--color-brand-500)',
|
||||
chipBg: 'var(--color-brand-50)',
|
||||
chipFg: 'var(--color-brand-600)',
|
||||
badge: 'bg-brand-50 text-brand-700 ring-brand-200',
|
||||
head: 'text-brand-800!',
|
||||
row: 'hover:bg-brand-50 focus-visible:ring-brand-500',
|
||||
num: 'text-brand-700!',
|
||||
link: 'text-brand-700 hover:text-brand-800 focus-visible:ring-brand-500',
|
||||
},
|
||||
teal: {
|
||||
rail: 'var(--color-teal-500)',
|
||||
chipBg: 'var(--color-teal-50)',
|
||||
chipFg: 'var(--color-teal-700)',
|
||||
badge: 'bg-teal-50 text-teal-700 ring-teal-100',
|
||||
head: 'text-teal-700!',
|
||||
row: 'hover:bg-teal-50 focus-visible:ring-teal-500',
|
||||
num: 'text-teal-700!',
|
||||
link: 'text-teal-700 hover:text-teal-600 focus-visible:ring-teal-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Khung 1 giai đoạn — nhãn/icon/tone đọc TỪ `STAGES`, không truyền chuỗi rời.
|
||||
function StageCard({ stage, children }: { stage: StageMeta; children: React.ReactNode }) {
|
||||
const { n, title, stepRange, icon: Icon, tone } = stage
|
||||
const t = tone ? STAGE_TONE[tone] : null
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full flex-col rounded-xl p-3.5',
|
||||
t ? 'card-accent' : 'border border-dashed border-slate-300 bg-slate-50',
|
||||
)}
|
||||
style={t ? { ['--accent' as string]: t.rail } : undefined}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ring-1 ring-inset',
|
||||
t ? t.badge : 'bg-white text-slate-600 ring-slate-300',
|
||||
)}
|
||||
>
|
||||
Giai đoạn {n}
|
||||
</span>
|
||||
<span
|
||||
className="icon-chip shrink-0"
|
||||
style={
|
||||
t
|
||||
? { ['--chip-bg' as string]: t.chipBg, ['--chip-fg' as string]: t.chipFg }
|
||||
: { ['--chip-bg' as string]: '#ffffff', ['--chip-fg' as string]: '#64748b' }
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className={cn('mt-2 text-[15px] leading-tight', t ? t.head : 'text-slate-600!')}>{title}</h3>
|
||||
<div className="mt-1 text-[11px] font-medium text-slate-500">{stepRange}</div>
|
||||
|
||||
<div className="mt-3 flex-1">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mũi tên nối giữa 2 giai đoạn — ngang trên desktop, xuống dòng trên mobile.
|
||||
function StageArrow() {
|
||||
return (
|
||||
<div aria-hidden className="flex items-center justify-center py-0.5 lg:px-0.5">
|
||||
<ChevronDown className="h-4 w-4 text-slate-400 lg:hidden" />
|
||||
<ChevronRight className="hidden h-5 w-5 text-slate-400 lg:block" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 1 dòng số liệu trong giai đoạn LIVE — bấm được, mở đúng màn hình đã lọc sẵn.
|
||||
// Là <button> ĐỘC LẬP (không lồng trong button cha) để giữ HTML/a11y hợp lệ.
|
||||
function StageStatRow({
|
||||
group,
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
loading,
|
||||
error,
|
||||
emphasis,
|
||||
onClick,
|
||||
}: {
|
||||
// Tên giai đoạn — vào aria-label để 2 nút cùng chữ "Chờ tôi duyệt" ở GĐ1/GĐ3
|
||||
// đọc ra KHÁC nhau trên screen-reader (reviewer FLAG-1, WCAG 2.4.6).
|
||||
group: string
|
||||
label: string
|
||||
value: number | undefined
|
||||
tone: StageTone
|
||||
loading: boolean
|
||||
error: boolean
|
||||
emphasis?: 'accent' | 'danger'
|
||||
onClick: () => void
|
||||
}) {
|
||||
const t = STAGE_TONE[tone]
|
||||
const highlight = !loading && !error && typeof value === 'number' && value > 0 && emphasis
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${group} — ${label}${typeof value === 'number' ? `: ${value}` : ''}`}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
// py-1.5 → vùng bấm ~32px cao, thoải mái trên cảm ứng (WCAG 2.5.8 tối thiểu 24px).
|
||||
'flex w-full items-center justify-between gap-2 rounded-md px-1.5 py-1.5 text-left transition',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
|
||||
t.row,
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 text-[11px] font-medium leading-tight text-slate-600">{label}</span>
|
||||
{loading ? (
|
||||
<span aria-hidden className="h-4 w-7 animate-pulse rounded bg-slate-200 motion-reduce:animate-none" />
|
||||
) : error ? (
|
||||
<span className="text-sm font-semibold text-slate-500" title="Không tải được số liệu">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'stat-value shrink-0 text-base tabular-nums',
|
||||
highlight === 'accent' && t.num,
|
||||
highlight === 'danger' && 'text-accent-600!',
|
||||
)}
|
||||
>
|
||||
{value ?? 0}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Link mở module của giai đoạn LIVE.
|
||||
function StageLink({ label, tone, onClick }: { label: string; tone: StageTone; onClick: () => void }) {
|
||||
const t = STAGE_TONE[tone]
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'mt-2 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-semibold transition',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
|
||||
t.link,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Thân của giai đoạn chưa triển khai — badge trung tính + 1 dòng mô tả.
|
||||
function StageSoon({ description, hint }: { description?: string; hint?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-200/80 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600">
|
||||
<Hourglass className="h-3 w-3" />
|
||||
Sắp triển khai
|
||||
</span>
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-slate-600">{description}</p>
|
||||
{hint && <p className="mt-1.5 text-[11px] font-semibold text-slate-500">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────────
|
||||
TẦNG LIÊN KẾT 2 — "Hành trình": mini-strip 4 chấm trên TỪNG DÒNG.
|
||||
Chấm của chính giai đoạn dòng đó mang trạng thái phiếu; 3 chấm còn lại mờ +
|
||||
@ -1084,11 +902,6 @@ export function UserDashboardPage() {
|
||||
/* ── Số liệu hero (5 query nền, y như trước — chỉ 2 query list rút pageSize 5 → 1
|
||||
vì 2 danh sách "gần đây" đã bị gỡ, hero chỉ cần `.total`). ───────────── */
|
||||
|
||||
const stats = useQuery({
|
||||
queryKey: ['my-dashboard'],
|
||||
queryFn: async () => (await api.get<MyDashboard>('/reports/my-dashboard')).data,
|
||||
})
|
||||
|
||||
const peTotalQuery = useQuery({
|
||||
queryKey: ['pipeline-pe-total'],
|
||||
queryFn: async () =>
|
||||
@ -1102,46 +915,6 @@ export function UserDashboardPage() {
|
||||
(await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 1 } })).data.total,
|
||||
})
|
||||
|
||||
// [S159] số phiếu Duyệt NCC ĐÃ DUYỆT (phase=7). pageSize=1 vì chỉ cần `total`.
|
||||
const peApprovedQuery = useQuery({
|
||||
queryKey: ['pipeline-pe-approved'],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await api.get<Paged<PeListItem>>('/purchase-evaluations', {
|
||||
params: { page: 1, pageSize: 1, phase: PurchaseEvaluationPhase.DaDuyet },
|
||||
})
|
||||
).data.total,
|
||||
})
|
||||
|
||||
// [S159] phiếu Duyệt NCC đang chờ CHÍNH TÔI duyệt. Endpoint /inbox trả mảng phẳng.
|
||||
// 🔴 DÙNG CHUNG queryKey + queryFn với InboxPage:77-78 (reviewer FLAG-5) — cache chia sẻ,
|
||||
// 2 màn luôn khớp số. CẤM đổi queryFn trả `.length` dưới key này: InboxPage cache MẢNG,
|
||||
// trùng key khác shape = vỡ cache lẫn nhau ⇒ đếm bằng `select` (transform per-observer,
|
||||
// không chạm cache).
|
||||
const peInboxQuery = useQuery({
|
||||
queryKey: ['pe-inbox'],
|
||||
queryFn: async () => (await api.get<PeListItem[]>('/purchase-evaluations/inbox')).data,
|
||||
select: d => d.length,
|
||||
})
|
||||
|
||||
const s = stats.data
|
||||
const peApproved = peApprovedQuery.data
|
||||
|
||||
const hasError =
|
||||
stats.isError ||
|
||||
contractTotalQuery.isError ||
|
||||
peTotalQuery.isError ||
|
||||
peApprovedQuery.isError ||
|
||||
peInboxQuery.isError
|
||||
|
||||
const retryFailed = () => {
|
||||
if (stats.isError) void stats.refetch()
|
||||
if (contractTotalQuery.isError) void contractTotalQuery.refetch()
|
||||
if (peTotalQuery.isError) void peTotalQuery.refetch()
|
||||
if (peApprovedQuery.isError) void peApprovedQuery.refetch()
|
||||
if (peInboxQuery.isError) void peInboxQuery.refetch()
|
||||
}
|
||||
|
||||
/* ── Section "Danh sách phiếu toàn trình" ──────────────────────────────── */
|
||||
|
||||
const [stage, setStage] = useState(1)
|
||||
@ -1219,135 +992,13 @@ export function UserDashboardPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
title="Tổng quan tất cả luồng duyệt"
|
||||
title="Tổng quan quy trình"
|
||||
description={`Xin chào ${user?.fullName ?? user?.email ?? 'bạn'} — toàn bộ hành trình từ Duyệt NCC đến Ký kết hợp đồng, 4 giai đoạn. Bấm vào số liệu để mở đúng danh sách.`}
|
||||
/>
|
||||
|
||||
{/* ── HERO: pipeline 4 giai đoạn ───────────────────────────────────── */}
|
||||
<section className="mb-6 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<header className="mb-3 flex items-center gap-2.5">
|
||||
<span className="icon-chip shrink-0">
|
||||
<Workflow className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
{/* Không dùng .label-eyebrow (brand-600 = 4.42:1, hụt AA 4.5) — brand-700
|
||||
giữ đúng nhận diện mà đạt 5.6:1. */}
|
||||
<div className="text-[11px] font-bold uppercase tracking-[0.07em] text-brand-700">
|
||||
Quy trình mua sắm → ký kết hợp đồng
|
||||
</div>
|
||||
<h2 className="text-sm leading-tight">Toàn trình 4 giai đoạn · 21 bước</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{hasError && (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg border border-amberx-500/40 bg-amberx-50 px-3 py-2 text-xs text-amberx-700">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
Không tải được một vài số liệu. Các phần còn lại vẫn hiển thị bình thường.
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={retryFailed}>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Thử lại
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-[1fr_auto_1fr_auto_1fr_auto_1fr] lg:items-stretch lg:gap-1.5">
|
||||
{/* GĐ1 — LIVE */}
|
||||
<StageCard stage={STAGES[0]}>
|
||||
<div className="space-y-0.5">
|
||||
<StageStatRow
|
||||
group={STAGES[0].title}
|
||||
label="Tổng phiếu"
|
||||
value={peTotalQuery.data}
|
||||
tone="brand"
|
||||
loading={peTotalQuery.isLoading}
|
||||
error={peTotalQuery.isError}
|
||||
onClick={() => navigate('/purchase-evaluations')}
|
||||
/>
|
||||
<StageStatRow
|
||||
group={STAGES[0].title}
|
||||
label="Đã duyệt"
|
||||
value={peApproved}
|
||||
tone="brand"
|
||||
loading={peApprovedQuery.isLoading}
|
||||
error={peApprovedQuery.isError}
|
||||
onClick={() => navigate(`/purchase-evaluations?phase=${PurchaseEvaluationPhase.DaDuyet}`)}
|
||||
/>
|
||||
<StageStatRow
|
||||
group={STAGES[0].title}
|
||||
label="Chờ tôi duyệt"
|
||||
value={peInboxQuery.data}
|
||||
tone="brand"
|
||||
loading={peInboxQuery.isLoading}
|
||||
error={peInboxQuery.isError}
|
||||
emphasis="accent"
|
||||
onClick={() => navigate('/purchase-evaluations?pendingMe=1')}
|
||||
/>
|
||||
</div>
|
||||
<StageLink label="Mở Duyệt NCC" tone="brand" onClick={() => navigate('/purchase-evaluations')} />
|
||||
</StageCard>
|
||||
|
||||
<StageArrow />
|
||||
|
||||
{/* GĐ2 — sắp triển khai */}
|
||||
<StageCard stage={STAGES[1]}>
|
||||
<StageSoon
|
||||
description={STAGES[1].soonDesc}
|
||||
hint={
|
||||
typeof peApproved === 'number' && peApproved > 0
|
||||
? `${peApproved} phiếu Duyệt NCC đã duyệt`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</StageCard>
|
||||
|
||||
<StageArrow />
|
||||
|
||||
{/* GĐ3 — LIVE */}
|
||||
<StageCard stage={STAGES[2]}>
|
||||
<div className="space-y-0.5">
|
||||
<StageStatRow
|
||||
group={STAGES[2].title}
|
||||
label="Tổng hợp đồng"
|
||||
value={contractTotalQuery.data}
|
||||
tone="teal"
|
||||
loading={contractTotalQuery.isLoading}
|
||||
error={contractTotalQuery.isError}
|
||||
onClick={() => navigate('/my-contracts')}
|
||||
/>
|
||||
<StageStatRow
|
||||
group={STAGES[2].title}
|
||||
label="Chờ tôi duyệt"
|
||||
value={s?.pendingMyApproval}
|
||||
tone="teal"
|
||||
loading={stats.isLoading}
|
||||
error={stats.isError}
|
||||
emphasis="accent"
|
||||
onClick={() => navigate('/inbox')}
|
||||
/>
|
||||
<StageStatRow
|
||||
group={STAGES[2].title}
|
||||
label="Đã quá hạn"
|
||||
value={s?.overdue}
|
||||
tone="teal"
|
||||
loading={stats.isLoading}
|
||||
error={stats.isError}
|
||||
emphasis="danger"
|
||||
onClick={() => navigate('/inbox')}
|
||||
/>
|
||||
</div>
|
||||
<StageLink label="Mở Hợp đồng" tone="teal" onClick={() => navigate('/my-contracts')} />
|
||||
</StageCard>
|
||||
|
||||
<StageArrow />
|
||||
|
||||
{/* GĐ4 — sắp triển khai */}
|
||||
<StageCard stage={STAGES[3]}>
|
||||
<StageSoon description={STAGES[3].soonDesc} />
|
||||
</StageCard>
|
||||
</div>
|
||||
</section>
|
||||
{/* [S159-dot5 owner: "O dep day, vay bo cai tren di nhe, cai duoi OK day"]
|
||||
HERO pipeline 4 card DA GO — tang tong-quan-lien-ket nay la thanh tab
|
||||
4 GD cua Danh sach chi tiet ben duoi (count + cot Hanh trinh per-row). */}
|
||||
|
||||
{/* ── DANH SÁCH PHIẾU TOÀN TRÌNH ───────────────────────────────────── */}
|
||||
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
|
||||
@ -29,11 +29,24 @@ export function MyContractsPage() {
|
||||
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
|
||||
const selectedId = searchParams.get('id')
|
||||
const search = searchParams.get('q') ?? ''
|
||||
// [S159-đợt5] leaf 'Đã duyệt' (?phase=9 — BE /contracts có sẵn param phase) và
|
||||
// 'Đã xóa' (?deleted=1 — endpoint /contracts/deleted mới, chỉ-xem).
|
||||
const phaseFilter = searchParams.get('phase') ? Number(searchParams.get('phase')) : null
|
||||
const showDeleted = searchParams.get('deleted') === '1'
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['my-contracts', typeFilter],
|
||||
queryKey: ['my-contracts', typeFilter, phaseFilter, showDeleted],
|
||||
queryFn: async () =>
|
||||
(await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data,
|
||||
(
|
||||
await api.get<Paged<ContractListItem>>(showDeleted ? '/contracts/deleted' : '/contracts', {
|
||||
params: {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
...(showDeleted && typeFilter != null ? { type: typeFilter } : {}),
|
||||
...(!showDeleted && phaseFilter != null ? { phase: phaseFilter } : {}),
|
||||
},
|
||||
})
|
||||
).data,
|
||||
})
|
||||
|
||||
const detail = useQuery({
|
||||
@ -57,6 +70,10 @@ export function MyContractsPage() {
|
||||
}, [list.data, typeFilter, search])
|
||||
|
||||
function selectContract(id: string) {
|
||||
// [S159-đợt5 FLAG-2 reviewer] Màn "Đã xóa" = CHỈ XEM DANH SÁCH (khuôn PE S155
|
||||
// `disabled={deletedView}`): detail endpoint KHÔNG IgnoreQueryFilters ⇒ HĐ đã xóa
|
||||
// 404 ⇒ desktop = 2 panel trắng, mobile = navigate sang trang 404. Chặn tại gốc.
|
||||
if (showDeleted) return
|
||||
// Desktop ≥ lg: cập nhật URL để render Panel 2/3 cạnh List.
|
||||
// Mobile: Panel 2/3 hidden → navigate fullpage /contracts/:id (UX khả dụng).
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
|
||||
@ -96,10 +113,22 @@ export function MyContractsPage() {
|
||||
{rows.length}
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={() => navigate(typeFilter ? `/contracts/new?type=${typeFilter}` : '/contracts/new')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tạo HĐ mới
|
||||
</Button>
|
||||
{showDeleted && (
|
||||
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-semibold text-slate-600 ring-1 ring-inset ring-slate-300">
|
||||
Đã xóa — chỉ xem
|
||||
</span>
|
||||
)}
|
||||
{!showDeleted && phaseFilter === 9 && (
|
||||
<span className="rounded-full bg-teal-50 px-2.5 py-1 text-[11px] font-semibold text-teal-700 ring-1 ring-inset ring-teal-200">
|
||||
Đã phát hành
|
||||
</span>
|
||||
)}
|
||||
{!showDeleted && (
|
||||
<Button onClick={() => navigate(typeFilter ? `/contracts/new?type=${typeFilter}` : '/contracts/new')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tạo HĐ mới
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* 3-panel grid — flex-1 để fill phần còn lại của viewport */}
|
||||
@ -130,7 +159,7 @@ export function MyContractsPage() {
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="Không có HĐ"
|
||||
description={typeFilter != null ? 'Loại HĐ này chưa có dữ liệu.' : 'Tạo HĐ mới để bắt đầu.'}
|
||||
description={showDeleted ? 'Chưa có HĐ nào bị xóa.' : typeFilter != null ? 'Loại HĐ này chưa có dữ liệu.' : 'Tạo HĐ mới để bắt đầu.'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -25,6 +25,7 @@ import type { AwAdminOverviewDto, AwDefinitionDto, AwLevelDto } from '@/types/ap
|
||||
const TYPE_LABEL: Record<number, string> = {
|
||||
1: 'Duyệt NCC',
|
||||
2: 'Duyệt NCC + Giải pháp',
|
||||
3: 'Hợp đồng (chung mọi loại)', // [S159-đợt5]
|
||||
}
|
||||
|
||||
// Color palette per Step (Phòng) — cycle 5 màu để phân biệt Bước. Tailwind JIT
|
||||
@ -50,7 +51,9 @@ const LEVEL_PALETTE = [
|
||||
export function WorkflowMatrixViewPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const rawType = Number(searchParams.get('type'))
|
||||
const typeInt = rawType === 1 || rawType === 2 ? rawType : 1
|
||||
// [S159-đợt5] +3 = Contract (ApplicableType.Contract — HĐ dùng CHUNG 1 bộ workflow V2
|
||||
// cho mọi ContractType ⇒ 7 leaf 'Luồng duyệt' của 7 nhóm HĐ đều trỏ về matrix này).
|
||||
const typeInt = rawType === 1 || rawType === 2 || rawType === 3 ? rawType : 1
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['workflow-matrix', typeInt],
|
||||
|
||||
Reference in New Issue
Block a user