Files
solution-erp/fe-user/src/pages/InboxPage.tsx
pqhuy1987 332a90f601
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m31s
[CLAUDE] FE-User: Inbox thêm section Phiếu Duyệt NCC chờ tôi
Optional polish (HANDOFF §C — "khi UAT phát sinh"). Drafter + TPB sẽ thấy
HĐ + Phiếu PE pending cùng InboxPage thay vì phải vào /purchase-evaluations
riêng.

Changes:
- useQuery thứ 2 cho /purchase-evaluations/inbox (endpoint đã sẵn)
- peRows filter theo search query (mã / tên gói thầu / project)
- Stats overdue/dueSoon đếm cả PE rows. totalValue chỉ HĐ (PE không có giá trị).
- Panel 1 chia 2 section sticky header:
  - "Hợp đồng (N)" — giữ behavior cũ, click → inline detail Panel 2
  - "Phiếu Duyệt NCC (M)" — click → navigate /purchase-evaluations/:id
    (page riêng, không inline vì PE entity shape khác Contract)
- EmptyState mới: "Không có HĐ hoặc Phiếu Duyệt NCC nào chờ"

Note: chỉ fe-user (đối tượng dùng Inbox), fe-admin có /system/inbox riêng
nếu cần — defer.

Build: fe-user pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:57:24 +07:00

349 lines
15 KiB
TypeScript

// 3-panel "Hộp thư" — HĐ chờ role tôi xử lý. Stats row + Panel 1 (list
// pending) | Panel 2 (detail content) | Panel 3 (workflow + lịch sử duyệt).
// Selected qua URL ?id= để bookmarkable.
//
// Khác MyContractsPage: data từ /contracts/inbox (BE đã filter HĐ chờ role
// tôi), thêm 4-card stats overdue/dueSoon/total/totalValue ở header.
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Inbox, AlertTriangle, Clock, FileText, Search, X, ClipboardList } from 'lucide-react'
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
import { PhaseBadge } from '@/components/PhaseBadge'
import { SlaTimer } from '@/components/SlaTimer'
import { EmptyState } from '@/components/EmptyState'
import { Input } from '@/components/ui/Input'
import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import type { ContractDetail, ContractListItem } from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
import {
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
PurchaseEvaluationTypeLabel,
type PeListItem,
} from '@/types/purchaseEvaluation'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
function StatPill({
icon: Icon,
label,
value,
tone = 'default',
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value: React.ReactNode
tone?: 'default' | 'warn' | 'danger'
}) {
const toneClass =
tone === 'danger' ? 'text-red-600 bg-red-50' :
tone === 'warn' ? 'text-amber-600 bg-amber-50' :
'text-brand-600 bg-brand-50'
return (
<div className="flex items-center gap-2 rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs">
<span className={cn('flex h-5 w-5 items-center justify-center rounded', toneClass)}>
<Icon className="h-3 w-3" />
</span>
<span className="text-slate-500">{label}</span>
<span className="font-semibold text-slate-900">{value}</span>
</div>
)
}
export function InboxPage() {
const navigate = useNavigate()
const { user } = useAuth()
const [searchParams, setSearchParams] = useSearchParams()
const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : null
const selectedId = searchParams.get('id')
const search = searchParams.get('q') ?? ''
const list = useQuery({
queryKey: ['inbox'],
queryFn: async () => (await api.get<ContractListItem[]>('/contracts/inbox')).data,
})
// PE inbox — phiếu Duyệt NCC chờ user xử lý (Optional polish §6.B HANDOFF).
// Click row → navigate /purchase-evaluations/:id thay vì inline detail (đỡ
// duplicate ContractDetailContent component cho PE entity khác shape).
const peList = useQuery({
queryKey: ['pe-inbox'],
queryFn: async () => (await api.get<PeListItem[]>('/purchase-evaluations/inbox')).data,
})
const detail = useQuery({
queryKey: ['contract', selectedId],
queryFn: async () => (await api.get<ContractDetail>(`/contracts/${selectedId}`)).data,
enabled: !!selectedId,
})
const allRows = list.data ?? []
const rows = useMemo(() => {
let items = allRows
if (typeFilter != null) items = items.filter(c => c.type === typeFilter)
if (search.trim()) {
const q = search.toLowerCase()
items = items.filter(c =>
(c.maHopDong ?? '').toLowerCase().includes(q) ||
(c.tenHopDong ?? '').toLowerCase().includes(q) ||
(c.supplierName ?? '').toLowerCase().includes(q),
)
}
return items
}, [allRows, typeFilter, search])
const peRows = useMemo(() => {
let items = peList.data ?? []
if (search.trim()) {
const q = search.toLowerCase()
items = items.filter(p =>
(p.maPhieu ?? '').toLowerCase().includes(q) ||
(p.tenGoiThau ?? '').toLowerCase().includes(q) ||
(p.projectName ?? '').toLowerCase().includes(q),
)
}
return items
}, [peList.data, search])
const stats = useMemo(() => {
const now = Date.now()
const dayMs = 24 * 60 * 60 * 1000
let overdue = 0
let dueSoon = 0
let totalValue = 0
for (const c of rows) {
totalValue += c.giaTri ?? 0
if (!c.slaDeadline) continue
const diff = new Date(c.slaDeadline).getTime() - now
if (diff < 0) overdue++
else if (diff < dayMs) dueSoon++
}
// PE rows tham gia stats overdue/dueSoon (KHÔNG totalValue — PE không có giá trị HĐ)
for (const p of peRows) {
if (!p.slaDeadline) continue
const diff = new Date(p.slaDeadline).getTime() - now
if (diff < 0) overdue++
else if (diff < dayMs) dueSoon++
}
return { total: rows.length + peRows.length, overdue, dueSoon, totalValue }
}, [rows, peRows])
function selectContract(id: string) {
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
const next = new URLSearchParams(searchParams)
next.set('id', id)
setSearchParams(next, { replace: false })
} else {
navigate(`/contracts/${id}`)
}
}
function clearSelection() {
const next = new URLSearchParams(searchParams)
next.delete('id')
setSearchParams(next, { replace: false })
}
function updateSearch(value: string) {
const next = new URLSearchParams(searchParams)
if (value) next.set('q', value)
else next.delete('q')
setSearchParams(next, { replace: true })
}
const typeLabel = typeFilter != null ? ContractTypeLabel[typeFilter] : null
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
{/* Header — title + stats inline để Panel 1 max chỗ */}
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-slate-200 bg-white px-6 py-3">
<div className="flex items-center gap-2">
<Inbox className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">
Hộp thư {typeLabel && <span className="text-slate-500">· {typeLabel}</span>}
</h1>
</div>
<div className="ml-auto flex flex-wrap gap-2">
<StatPill icon={Inbox} label="Cần xử lý" value={stats.total} />
<StatPill icon={Clock} label="Sắp quá hạn" value={stats.dueSoon} tone="warn" />
<StatPill icon={AlertTriangle} label="Quá hạn" value={stats.overdue} tone="danger" />
<StatPill icon={FileText} label="Giá trị" value={fmtMoney(stats.totalValue)} />
</div>
</header>
{user?.roles.length ? (
<div className="shrink-0 border-b border-slate-200 bg-amber-50 px-6 py-1.5 text-[11px] text-amber-700">
Vai trò bạn đang xử : <span className="font-medium">{user.roles.join(', ')}</span>
</div>
) : null}
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[320px_1fr_360px]">
{/* Panel 1 — Pending list */}
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
<div className="border-b border-slate-200 p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={search}
onChange={e => updateSearch(e.target.value)}
placeholder="Tìm theo mã / tên / NCC…"
className="pl-8"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{list.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
))}
</div>
)}
{!list.isLoading && rows.length === 0 && peRows.length === 0 && (
<div className="p-6">
<EmptyState
icon={Inbox}
title="Hộp thư trống"
description={
typeFilter != null
? `Không có ${ContractTypeLabel[typeFilter]} nào đang chờ.`
: 'Không có HĐ hoặc Phiếu Duyệt NCC nào chờ bạn xử lý.'
}
/>
</div>
)}
{/* Section 1 — Hợp đồng chờ tôi */}
{rows.length > 0 && (
<>
<div className="sticky top-0 z-10 flex items-center gap-2 border-b border-slate-200 bg-slate-50 px-3 py-1.5 text-[11px] font-medium text-slate-600">
<FileText className="h-3 w-3" />
Hợp đng <span className="text-slate-400">({rows.length})</span>
</div>
<ul className="divide-y divide-slate-100">
{rows.map(c => {
const overdue = c.slaDeadline && new Date(c.slaDeadline).getTime() < Date.now()
return (
<li key={c.id}>
<button
onClick={() => selectContract(c.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
selectedId === c.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
overdue && selectedId !== c.id && 'border-l-2 border-l-red-400',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-900">
{c.tenHopDong ?? '(chưa đặt tên)'}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{c.maHopDong ?? '—'}</span>
<span>·</span>
<span className="truncate">{c.supplierName}</span>
</div>
</div>
<PhaseBadge phase={c.phase} className="shrink-0 text-[10px]" />
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
<span>{fmtMoney(c.giaTri)}</span>
<SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} />
</div>
</button>
</li>
)
})}
</ul>
</>
)}
{/* Section 2 — Phiếu Duyệt NCC chờ tôi (PE) */}
{peRows.length > 0 && (
<>
<div className="sticky top-0 z-10 flex items-center gap-2 border-b border-slate-200 bg-slate-50 px-3 py-1.5 text-[11px] font-medium text-slate-600">
<ClipboardList className="h-3 w-3" />
Phiếu Duyệt NCC <span className="text-slate-400">({peRows.length})</span>
</div>
<ul className="divide-y divide-slate-100">
{peRows.map(p => {
const overdue = p.slaDeadline && new Date(p.slaDeadline).getTime() < Date.now()
return (
<li key={p.id}>
<button
onClick={() => navigate(`/purchase-evaluations/${p.id}`)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
overdue && 'border-l-2 border-l-red-400',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-900">
{p.tenGoiThau}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{p.maPhieu ?? '—'}</span>
<span>·</span>
<span className="truncate">{p.projectName}</span>
</div>
</div>
<span className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px]',
PurchaseEvaluationPhaseColor[p.phase],
)}>
{PurchaseEvaluationPhaseLabel[p.phase]}
</span>
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
<span>{PurchaseEvaluationTypeLabel[p.type]}</span>
<SlaTimer deadline={p.slaDeadline} createdAt={p.createdAt} />
</div>
</button>
</li>
)
})}
</ul>
</>
)}
</div>
</aside>
{/* Panel 2 — Detail content */}
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
{!selectedId && (
<EmptyState
icon={Inbox}
title="Chọn 1 HĐ ở danh sách bên trái"
description="Chi tiết HĐ + góp ý + đính kèm + nút Duyệt sẽ hiển thị ở đây."
/>
)}
{selectedId && detail.isLoading && (
<div className="text-sm text-slate-500">Đang tải </div>
)}
{selectedId && detail.data && (
<ContractDetailContent contract={detail.data} onBack={clearSelection} />
)}
</main>
{/* Panel 3 — Workflow + history */}
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
{!selectedId && (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
<X className="mx-auto mb-2 h-5 w-5" />
Quy trình duyệt sẽ hiện khi chọn .
</div>
)}
{selectedId && detail.data && <WorkflowHistoryPanel contract={detail.data} />}
</aside>
</div>
</div>
)
}