diff --git a/fe-user/src/pages/InboxPage.tsx b/fe-user/src/pages/InboxPage.tsx
index 17dc509..5ee4a38 100644
--- a/fe-user/src/pages/InboxPage.tsx
+++ b/fe-user/src/pages/InboxPage.tsx
@@ -1,19 +1,28 @@
+// 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 } from 'lucide-react'
-import { PageHeader } from '@/components/PageHeader'
-import { DataTable, type Column } from '@/components/DataTable'
+import { Inbox, AlertTriangle, Clock, FileText, Search, X } 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 type { ContractListItem } from '@/types/contracts'
+import { cn } from '@/lib/cn'
+import type { ContractDetail, ContractListItem } from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
-function StatCard({
+function StatPill({
icon: Icon,
label,
value,
@@ -25,14 +34,16 @@ function StatCard({
tone?: 'default' | 'warn' | 'danger'
}) {
const toneClass =
- tone === 'danger' ? 'text-red-600' : tone === 'warn' ? 'text-amber-600' : 'text-brand-600'
+ tone === 'danger' ? 'text-red-600 bg-red-50' :
+ tone === 'warn' ? 'text-amber-600 bg-amber-50' :
+ 'text-brand-600 bg-brand-50'
return (
-
-
-
{value}
+
+
+
+
+ {label}
+ {value}
)
}
@@ -40,17 +51,38 @@ function StatCard({
export function InboxPage() {
const navigate = useNavigate()
const { user } = useAuth()
- const [searchParams] = useSearchParams()
+ 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
('/contracts/inbox')).data,
})
+ const detail = useQuery({
+ queryKey: ['contract', selectedId],
+ queryFn: async () => (await api.get(`/contracts/${selectedId}`)).data,
+ enabled: !!selectedId,
+ })
+
const allRows = list.data ?? []
- // Apply type filter from sidebar nested menu (?type=X)
- const rows = typeFilter == null ? allRows : allRows.filter(c => c.type === typeFilter)
+
+ 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 stats = useMemo(() => {
const now = Date.now()
@@ -68,69 +100,156 @@ export function InboxPage() {
return { total: rows.length, overdue, dueSoon, totalValue }
}, [rows])
- const columns: Column[] = [
- {
- key: 'maHopDong',
- header: 'Mã HĐ',
- width: 'w-48',
- render: c => {c.maHopDong ?? '—'},
- },
- { key: 'tenHopDong', header: 'Tên HĐ', render: c => c.tenHopDong ?? '—' },
- {
- key: 'type',
- header: 'Loại',
- width: 'w-32',
- render: c => ContractTypeLabel[c.type] ?? '—',
- },
- {
- key: 'phase',
- header: 'Phase hiện tại',
- width: 'w-36',
- render: c => ,
- },
- { key: 'supplierName', header: 'NCC', render: c => c.supplierName },
- {
- key: 'giaTri',
- header: 'Giá trị',
- align: 'right',
- width: 'w-32',
- render: c => fmtMoney(c.giaTri),
- },
- {
- key: 'slaDeadline',
- header: 'SLA',
- width: 'w-40',
- render: c => ,
- },
- ]
+ 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 (
-
-
-
- Hộp thư
-
- }
- description={`HĐ chờ vai trò ${user?.roles.join(', ') ?? ''} xử lý. Click row để xem chi tiết.`}
- />
+
+ {/* Header — title + stats inline để Panel 1 max chỗ */}
+
-
-
-
-
-
+ {user?.roles.length ? (
+
+ Vai trò bạn đang xử lý: {user.roles.join(', ')}
+
+ ) : null}
+
+
+ {/* Panel 1 — Pending list */}
+
+
+ {/* Panel 2 — Detail content */}
+
+ {!selectedId && (
+
+ )}
+ {selectedId && detail.isLoading && (
+ Đang tải HĐ…
+ )}
+ {selectedId && detail.data && (
+
+ )}
+
+
+ {/* Panel 3 — Workflow + history */}
+
-
-
c.id}
- isLoading={list.isLoading}
- empty="Không có HĐ nào chờ bạn xử lý."
- onRowClick={c => navigate(`/contracts/${c.id}`)}
- />
)
}