[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 { 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,46 +213,105 @@ 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>
|
||||||
)}
|
)}
|
||||||
<ul className="divide-y divide-slate-100">
|
|
||||||
{rows.map(c => {
|
{/* Section 1 — Hợp đồng chờ tôi */}
|
||||||
const overdue = c.slaDeadline && new Date(c.slaDeadline).getTime() < Date.now()
|
{rows.length > 0 && (
|
||||||
return (
|
<>
|
||||||
<li key={c.id}>
|
<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">
|
||||||
<button
|
<FileText className="h-3 w-3" />
|
||||||
onClick={() => selectContract(c.id)}
|
Hợp đồng <span className="text-slate-400">({rows.length})</span>
|
||||||
className={cn(
|
</div>
|
||||||
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
|
<ul className="divide-y divide-slate-100">
|
||||||
selectedId === c.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
|
{rows.map(c => {
|
||||||
overdue && selectedId !== c.id && 'border-l-2 border-l-red-400',
|
const overdue = c.slaDeadline && new Date(c.slaDeadline).getTime() < Date.now()
|
||||||
)}
|
return (
|
||||||
>
|
<li key={c.id}>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<button
|
||||||
<div className="min-w-0 flex-1">
|
onClick={() => selectContract(c.id)}
|
||||||
<div className="truncate text-[13px] font-medium text-slate-900">
|
className={cn(
|
||||||
{c.tenHopDong ?? '(chưa đặt tên)'}
|
'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>
|
||||||
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
|
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
|
||||||
<span className="font-mono">{c.maHopDong ?? '—'}</span>
|
<span>{fmtMoney(c.giaTri)}</span>
|
||||||
<span>·</span>
|
<SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} />
|
||||||
<span className="truncate">{c.supplierName}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
<PhaseBadge phase={c.phase} className="shrink-0 text-[10px]" />
|
</li>
|
||||||
</div>
|
)
|
||||||
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
|
})}
|
||||||
<span>{fmtMoney(c.giaTri)}</span>
|
</ul>
|
||||||
<SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} />
|
</>
|
||||||
</div>
|
)}
|
||||||
</button>
|
|
||||||
</li>
|
{/* Section 2 — Phiếu Duyệt NCC chờ tôi (PE) */}
|
||||||
)
|
{peRows.length > 0 && (
|
||||||
})}
|
<>
|
||||||
</ul>
|
<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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user