[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
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:
@ -7,7 +7,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
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 { WorkflowHistoryPanel } from '@/components/contracts/WorkflowHistoryPanel'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
@ -19,6 +19,12 @@ 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')
|
||||
|
||||
@ -62,6 +68,14 @@ export function InboxPage() {
|
||||
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,
|
||||
@ -84,6 +98,19 @@ export function InboxPage() {
|
||||
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
|
||||
@ -97,8 +124,15 @@ export function InboxPage() {
|
||||
if (diff < 0) overdue++
|
||||
else if (diff < dayMs) dueSoon++
|
||||
}
|
||||
return { total: rows.length, overdue, dueSoon, totalValue }
|
||||
}, [rows])
|
||||
// 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) {
|
||||
@ -171,7 +205,7 @@ export function InboxPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!list.isLoading && rows.length === 0 && (
|
||||
{!list.isLoading && rows.length === 0 && peRows.length === 0 && (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
@ -179,46 +213,105 @@ export function InboxPage() {
|
||||
description={
|
||||
typeFilter != null
|
||||
? `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>
|
||||
)}
|
||||
<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)'}
|
||||
|
||||
{/* 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-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 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>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user