Files
solution-erp/fe-user/src/pages/UserDashboardPage.tsx
pqhuy1987 9e12e80b48
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m13s
[CLAUDE] FE-Admin·FE-User: badge " Đã được CEO duyệt" cho phiếu đi hết lên CEO (anh Kiệt FDC UAT)
- PeFinalizeChip (single-source-of-truth pill): +nhánh phase===DaDuyet && !endsBeforeCeo → " Đã được CEO duyệt" (đối xứng chip "⚑ Kết thúc · X" cho endsBeforeCeo=true)
- Wire phase ở 6 call-site (PeListPanel + PurchaseEvaluationsListPage ×2 app + Inbox + Dashboard user)
- FE-only, tận dụng endsBeforeCeo runtime (Mig 60 fix-3), no BE/mig; mirror 2-app SHA da9c3ed9, build ×2 PASS 0-TS-err

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:52:17 +07:00

310 lines
12 KiB
TypeScript

// "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.
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { FileText, CheckCircle2, AlertTriangle, Pencil, Clock, Inbox, Coins } 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 { 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 { ContractTypeLabel } from '@/types/forms'
import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip'
import {
getPeDisplayStatus,
PeDisplayStatusColor,
PeDisplayStatusLabel,
type PeListItem,
} from '@/types/purchaseEvaluation'
type MyDashboard = {
draftsInProgress: number
pendingMyApproval: number
dueSoon: number
overdue: number
draftsTotalValue: number
}
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')
}
// 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.
type StatTone = 'default' | 'teal' | 'warn' | 'good' | 'danger' | 'violet'
const STAT_TONE: Record<StatTone, { rail: string; chipBg: string; chipFg: string }> = {
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)' },
}
function StatCard({
icon: Icon,
label,
value,
hint,
tone = 'default',
onClick,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value: React.ReactNode
hint?: string
tone?: StatTone
onClick?: () => void
}) {
const t = STAT_TONE[tone]
return (
<button
onClick={onClick}
disabled={!onClick}
className="card-accent flex flex-col p-4 text-left 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"
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>}
</button>
)
}
export function UserDashboardPage() {
const navigate = useNavigate()
const { user } = useAuth()
const stats = useQuery({
queryKey: ['my-dashboard'],
queryFn: async () => (await api.get<MyDashboard>('/reports/my-dashboard')).data,
})
const recent = useQuery({
queryKey: ['my-contracts-recent'],
queryFn: async () =>
(await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 5 } })).data,
})
// [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'],
queryFn: async () =>
(await api.get<Paged<PeListItem>>('/purchase-evaluations', { params: { page: 1, pageSize: 5 } })).data,
})
const s = stats.data
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."
/>
<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>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<StatCard
icon={Pencil}
label="HĐ đang soạn"
value={s?.draftsInProgress ?? '—'}
hint="Chưa hoàn tất"
onClick={() => navigate('/my-contracts')}
/>
<StatCard
icon={Inbox}
label="Chờ tôi duyệt"
value={s?.pendingMyApproval ?? '—'}
hint="Vào Hộp thư"
tone="teal"
onClick={() => navigate('/inbox')}
/>
<StatCard
icon={Clock}
label="Sắp quá hạn"
value={s?.dueSoon ?? '—'}
hint="< 24h"
tone="warn"
onClick={() => navigate('/inbox')}
/>
<StatCard
icon={AlertTriangle}
label="Đã quá hạn"
value={s?.overdue ?? '—'}
tone="danger"
onClick={() => navigate('/inbox')}
/>
<StatCard
icon={Coins}
label="Tổng giá trị nháp"
value={s ? fmtMoney(s.draftsTotalValue) : '—'}
tone="violet"
onClick={() => navigate('/my-contracts')}
/>
</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>
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 mới
</Button>
}
/>
</div>
)}
<ul className="divide-y divide-slate-100">
{recent.data?.items?.map(c => (
<li key={c.id}>
<button
onClick={() => navigate(`/my-contracts?id=${c.id}`)}
className="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-slate-50"
>
<div className="min-w-0 flex-1">
<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>
{/* [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>
{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.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"
>
<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>
</button>
</li>
))}
</ul>
</section>
</div>
)
}