All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m13s
- 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>
310 lines
12 KiB
TypeScript
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>
|
|
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"
|
|
>
|
|
<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>
|
|
)
|
|
}
|