[CLAUDE] FE-User: Inbox thêm section Phiếu Duyệt NCC chờ tôi
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m31s

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>
This commit is contained in:
pqhuy1987
2026-05-06 15:57:24 +07:00
parent 7dc0233f08
commit 332a90f601

View File

@ -7,7 +7,7 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { Inbox, AlertTriangle, Clock, FileText, Search, X } from 'lucide-react' import { Inbox, AlertTriangle, Clock, FileText, Search, X, ClipboardList } from 'lucide-react'
import { ContractDetailContent } from '@/components/contracts/ContractDetailContent' import { ContractDetailContent } from '@/components/contracts/ContractDetailContent'
import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel' import { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
import { PhaseBadge } from '@/components/PhaseBadge' import { PhaseBadge } from '@/components/PhaseBadge'
@ -19,6 +19,12 @@ import { api } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import type { ContractDetail, ContractListItem } from '@/types/contracts' import type { ContractDetail, ContractListItem } from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms' import { ContractTypeLabel } from '@/types/forms'
import {
PurchaseEvaluationPhaseColor,
PurchaseEvaluationPhaseLabel,
PurchaseEvaluationTypeLabel,
type PeListItem,
} from '@/types/purchaseEvaluation'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN') const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
@ -62,6 +68,14 @@ export function InboxPage() {
queryFn: async () => (await api.get<ContractListItem[]>('/contracts/inbox')).data, 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({ const detail = useQuery({
queryKey: ['contract', selectedId], queryKey: ['contract', selectedId],
queryFn: async () => (await api.get<ContractDetail>(`/contracts/${selectedId}`)).data, queryFn: async () => (await api.get<ContractDetail>(`/contracts/${selectedId}`)).data,
@ -84,6 +98,19 @@ export function InboxPage() {
return items return items
}, [allRows, typeFilter, search]) }, [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 stats = useMemo(() => {
const now = Date.now() const now = Date.now()
const dayMs = 24 * 60 * 60 * 1000 const dayMs = 24 * 60 * 60 * 1000
@ -97,8 +124,15 @@ export function InboxPage() {
if (diff < 0) overdue++ if (diff < 0) overdue++
else if (diff < dayMs) dueSoon++ else if (diff < dayMs) dueSoon++
} }
return { total: rows.length, overdue, dueSoon, totalValue } // PE rows tham gia stats overdue/dueSoon (KHÔNG totalValue — PE không có giá trị HĐ)
}, [rows]) 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) { function selectContract(id: string) {
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) { if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
@ -171,7 +205,7 @@ export function InboxPage() {
))} ))}
</div> </div>
)} )}
{!list.isLoading && rows.length === 0 && ( {!list.isLoading && rows.length === 0 && peRows.length === 0 && (
<div className="p-6"> <div className="p-6">
<EmptyState <EmptyState
icon={Inbox} icon={Inbox}
@ -179,11 +213,19 @@ export function InboxPage() {
description={ description={
typeFilter != null typeFilter != null
? `Không có ${ContractTypeLabel[typeFilter]} nào đang chờ.` ? `Không có ${ContractTypeLabel[typeFilter]} nào đang chờ.`
: 'Không có HĐ nào chờ bạn xử lý.' : 'Không có HĐ hoặc Phiếu Duyệt NCC nào chờ bạn xử lý.'
} }
/> />
</div> </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"> <ul className="divide-y divide-slate-100">
{rows.map(c => { {rows.map(c => {
const overdue = c.slaDeadline && new Date(c.slaDeadline).getTime() < Date.now() const overdue = c.slaDeadline && new Date(c.slaDeadline).getTime() < Date.now()
@ -219,6 +261,57 @@ export function InboxPage() {
) )
})} })}
</ul> </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> </div>
</aside> </aside>