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 ( -
-
-
{label}
- -
-
{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ỗ */} +
+
+ +

+ Hộp thư {typeLabel && · {typeLabel}} +

+
+
+ + + + +
+
-
- - - - + {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}`)} - />
) }