All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m32s
Anh feedback 2026-05-21: "Folder cấp dưới dự án là theo năm và dưới năm là theo NCC nhé". Plan AG3 chỉ 1-level Project > PE. Plan AG5 extend xuống 3 cấp: Năm + NCC. Group structure: - Level 1: 📁 Project (bg-slate-50, font-medium 13px) - Level 2: 📅 Năm {year} (border-l ml-3, 12px) - Level 3: 🏢 NCC (border-l ml-3, 12px, italic slate-400 nếu "Chưa chọn NCC") - Leaf: PE card (border-l ml-3, giữ nguyên content) Sort: - Project A-Z (vi locale) - Năm DESC (2026 trước 2025) - NCC A-Z (vi locale) - PE within NCC: createdAt DESC Fallback: - empty projectName → "(Dự án đã xoá)" - selectedSupplierName null (PE chưa DaDuyet) → "(Chưa chọn NCC)" group + italic style Drop redundant selectedSupplierName line trong PE card (đã hiện ở NCC group header). localStorage keys: - Project: projectId - Năm: `${projectId}::y${year}` - NCC: `${projectId}::y${year}::s${supplierId|'_none_'}` Verify: - npm build fe-user PASS 0 TS err 1292.68 KB (gzip 337.18 KB) 1907 modules - npm build fe-admin PASS 0 TS err 1404.02 KB (gzip 357.70 KB) 1926 modules - 2 file SHA256 IDENTICAL E5FE4979... (mirror §3.9) - KHÔNG BE change, KHÔNG Mig, KHÔNG test (UAT mode) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
465 lines
24 KiB
TypeScript
465 lines
24 KiB
TypeScript
// List + Detail phiếu Duyệt NCC — 3-panel: List | Detail tabs | Workflow + history.
|
|
// URL params: type (filter A/B), pendingMe (1=inbox), id (selected), q (search).
|
|
// Plan AG Phase 1 (S26 2026-05-21) — Tree view 2-level Project > Gói thầu > PE
|
|
// UAT feedback bro Tra Sol: flat list "đám rừng" → Outlook folder structure.
|
|
// FE-only group view (no schema change) — Phase 2 ProjectPackage defer sau UAT confirm.
|
|
import { useMemo, useState } from 'react'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { toast } from 'sonner'
|
|
import { ClipboardCheck, Search, X } from 'lucide-react'
|
|
import { Input } from '@/components/ui/Input'
|
|
import { Select } from '@/components/ui/Select'
|
|
import { EmptyState } from '@/components/EmptyState'
|
|
import { api } from '@/lib/api'
|
|
import { getErrorMessage } from '@/lib/apiError'
|
|
import { cn } from '@/lib/cn'
|
|
import type { Paged } from '@/types/master'
|
|
import {
|
|
PeDisplayStatus,
|
|
PeDisplayStatusColor,
|
|
PeDisplayStatusLabel,
|
|
PurchaseEvaluationPhase,
|
|
PurchaseEvaluationTypeLabel,
|
|
getPeDisplayStatus,
|
|
type PeDetailBundle,
|
|
type PeListItem,
|
|
} from '@/types/purchaseEvaluation'
|
|
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
|
|
import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel'
|
|
|
|
export function PurchaseEvaluationsListPage() {
|
|
const navigate = useNavigate()
|
|
const qc = useQueryClient()
|
|
const [sp, setSp] = useSearchParams()
|
|
const typeFilter = sp.get('type') ? Number(sp.get('type')) : null
|
|
const pendingMe = sp.get('pendingMe') === '1'
|
|
const search = sp.get('q') ?? ''
|
|
const phase = sp.get('phase') ?? ''
|
|
const approvalWorkflowId = sp.get('awId') ?? '' // Mig 23 — filter quy trình
|
|
const selectedId = sp.get('id')
|
|
|
|
// Mig 23 — list quy trình duyệt V2 cho dropdown filter (filter theo type screen)
|
|
const approvalWorkflows = useQuery({
|
|
queryKey: ['approval-workflows-v2-filter', typeFilter],
|
|
queryFn: async () => {
|
|
if (!typeFilter) return []
|
|
const res = await api.get<{ types: { applicableType: number; history: { id: string; code: string; version: number; name: string; isActive: boolean }[] }[] }>(
|
|
'/approval-workflows-v2',
|
|
{ params: { applicableType: typeFilter } },
|
|
)
|
|
return res.data.types.find(t => t.applicableType === typeFilter)?.history ?? []
|
|
},
|
|
enabled: !!typeFilter,
|
|
})
|
|
|
|
const list = useQuery({
|
|
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase, approvalWorkflowId }],
|
|
queryFn: async () => {
|
|
if (pendingMe) {
|
|
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
|
|
params: {
|
|
type: typeFilter ?? undefined,
|
|
approvalWorkflowId: approvalWorkflowId || undefined,
|
|
},
|
|
})
|
|
return { items: res.data, total: res.data.length, page: 1, pageSize: res.data.length }
|
|
}
|
|
const res = await api.get<Paged<PeListItem>>('/purchase-evaluations', {
|
|
params: {
|
|
pageSize: 50,
|
|
search: search || undefined,
|
|
type: typeFilter ?? undefined,
|
|
phase: phase || undefined,
|
|
approvalWorkflowId: approvalWorkflowId || undefined,
|
|
},
|
|
})
|
|
return res.data
|
|
},
|
|
})
|
|
|
|
const detail = useQuery({
|
|
queryKey: ['pe-detail', selectedId],
|
|
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${selectedId}`)).data,
|
|
enabled: !!selectedId,
|
|
})
|
|
|
|
const del = useMutation({
|
|
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`),
|
|
onSuccess: () => {
|
|
toast.success('Đã xóa phiếu.')
|
|
setParam('id', null)
|
|
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
|
},
|
|
onError: e => toast.error(getErrorMessage(e)),
|
|
})
|
|
|
|
function setParam(key: string, value: string | null) {
|
|
const next = new URLSearchParams(sp)
|
|
if (value == null || value === '') next.delete(key)
|
|
else next.set(key, value)
|
|
if (key !== 'id') next.delete('page')
|
|
setSp(next, { replace: key === 'q' })
|
|
}
|
|
|
|
function selectRow(id: string) {
|
|
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
|
|
setParam('id', id)
|
|
} else {
|
|
navigate(`/purchase-evaluations/${id}`)
|
|
}
|
|
}
|
|
|
|
const allRows = list.data?.items ?? []
|
|
// Duyệt (pendingMe) → filter cứng client-side chỉ "Đã gửi duyệt" (Nháp/Trả lại/
|
|
// Đã duyệt/Từ chối loại bỏ). BE /inbox hiện loose UAT có thể trả phiếu Nháp →
|
|
// FE filter để UX đúng kỳ vọng. Phân quyền strict V2 BE pending Session 18+.
|
|
|
|
// Plan AG5 — Group 3-level Project > Năm > NCC > PE (anh feedback 2026-05-21:
|
|
// "Folder cấp dưới dự án là theo năm và dưới năm là theo NCC"). Filter pendingMe TRƯỚC group.
|
|
// Year extract từ createdAt.getFullYear(). NCC = selectedSupplierName fallback "(Chưa chọn NCC)"
|
|
// khi PE chưa DaDuyet. Sort: Project A-Z (vi) + Year DESC + NCC A-Z (vi) + PE createdAt DESC.
|
|
type SupplierGroup = {
|
|
supplierId: string | null
|
|
supplierName: string
|
|
items: PeListItem[]
|
|
}
|
|
type YearGroup = {
|
|
year: number
|
|
suppliers: SupplierGroup[]
|
|
totalCount: number
|
|
}
|
|
type ProjectGroup = {
|
|
projectId: string | null
|
|
projectName: string
|
|
years: YearGroup[]
|
|
totalCount: number
|
|
}
|
|
const projectGroups = useMemo<ProjectGroup[]>(() => {
|
|
const filtered = pendingMe
|
|
? allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet)
|
|
: allRows
|
|
const projectMap = new Map<string, ProjectGroup>()
|
|
for (const p of filtered) {
|
|
const projKey = p.projectId ?? '__no_project__'
|
|
const projName = p.projectName?.trim() || '(Dự án đã xoá)'
|
|
if (!projectMap.has(projKey)) {
|
|
projectMap.set(projKey, { projectId: p.projectId ?? null, projectName: projName, years: [], totalCount: 0 })
|
|
}
|
|
const pg = projectMap.get(projKey)!
|
|
const year = new Date(p.createdAt).getFullYear()
|
|
let yg = pg.years.find(y => y.year === year)
|
|
if (!yg) {
|
|
yg = { year, suppliers: [], totalCount: 0 }
|
|
pg.years.push(yg)
|
|
}
|
|
const supKey = p.selectedSupplierId ?? '__no_supplier__'
|
|
const supName = p.selectedSupplierName?.trim() || '(Chưa chọn NCC)'
|
|
let sg = yg.suppliers.find(s => (s.supplierId ?? '__no_supplier__') === supKey)
|
|
if (!sg) {
|
|
sg = { supplierId: p.selectedSupplierId ?? null, supplierName: supName, items: [] }
|
|
yg.suppliers.push(sg)
|
|
}
|
|
sg.items.push(p)
|
|
yg.totalCount++
|
|
pg.totalCount++
|
|
}
|
|
const arr = Array.from(projectMap.values())
|
|
arr.sort((a, b) => a.projectName.localeCompare(b.projectName, 'vi'))
|
|
for (const pg of arr) {
|
|
pg.years.sort((a, b) => b.year - a.year)
|
|
for (const yg of pg.years) {
|
|
yg.suppliers.sort((a, b) => a.supplierName.localeCompare(b.supplierName, 'vi'))
|
|
for (const sg of yg.suppliers) {
|
|
sg.items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
|
}
|
|
}
|
|
}
|
|
return arr
|
|
}, [allRows, pendingMe])
|
|
|
|
// Total row count cho header badge (pendingMe đếm filtered, Danh sách đếm BE total).
|
|
const totalRowCount = projectGroups.reduce((sum, pg) => sum + pg.totalCount, 0)
|
|
|
|
// Plan AG2 — Expand state localStorage Set<string> (projectId only, drop ::gtKey suffix).
|
|
// Default empty Set (all collapse). Single-PE project skip <details> wrapper (render flat).
|
|
const STORAGE_KEY = 'pe_list_expanded_projects'
|
|
const [expandedSet, setExpandedSet] = useState<Set<string>>(() => {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY)
|
|
return raw ? new Set(JSON.parse(raw) as string[]) : new Set<string>()
|
|
} catch {
|
|
return new Set<string>()
|
|
}
|
|
})
|
|
const isExpanded = (key: string) => expandedSet.has(key)
|
|
const toggleExpand = (key: string, open: boolean) => {
|
|
setExpandedSet(prev => {
|
|
const next = new Set(prev)
|
|
if (open) next.add(key)
|
|
else next.delete(key)
|
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...next])) } catch {}
|
|
return next
|
|
})
|
|
}
|
|
|
|
const headerTitle = typeFilter
|
|
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
|
|
: pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC'
|
|
|
|
return (
|
|
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<ClipboardCheck className="h-5 w-5 text-slate-500" />
|
|
<h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
|
|
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
|
{pendingMe ? totalRowCount : (list.data?.total ?? 0)}
|
|
</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[400px_1fr_360px]">
|
|
{/* Panel 1: List */}
|
|
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
|
|
<div className="space-y-2 border-b border-slate-200 p-3">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
|
<Input
|
|
value={search}
|
|
onChange={e => setParam('q', e.target.value)}
|
|
placeholder="Tìm mã / tên gói thầu / dự án…"
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
{/* Mig 23 — Dropdown quy trình duyệt CHỈ ở "Duyệt" (pendingMe).
|
|
Danh sách giữ nguyên (chỉ filter trạng thái) — user feedback
|
|
muốn Danh sách show hết phiếu không filter quy trình. */}
|
|
{pendingMe && (
|
|
<Select value={approvalWorkflowId} onChange={e => setParam('awId', e.target.value)}>
|
|
<option value="">Tất cả quy trình duyệt</option>
|
|
{approvalWorkflows.data?.map(w => (
|
|
<option key={w.id} value={w.id}>
|
|
{w.code} v{String(w.version).padStart(2, '0')} — {w.name}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
)}
|
|
{/* Duyệt (pendingMe) → filter cứng "Đã gửi duyệt", ẩn dropdown trạng thái.
|
|
Danh sách (pendingMe=false) → giữ dropdown cho user filter mọi trạng thái. */}
|
|
{pendingMe ? (
|
|
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-700">
|
|
Lọc cố định: <strong>Đã gửi duyệt</strong> (phiếu đang chờ duyệt)
|
|
</div>
|
|
) : (
|
|
<Select value={phase} onChange={e => setParam('phase', e.target.value)}>
|
|
<option value="">Tất cả trạng thái</option>
|
|
{Object.values(PeDisplayStatus).map(s => {
|
|
const phaseValue = s === PeDisplayStatus.Nhap
|
|
? String(PurchaseEvaluationPhase.DangSoanThao)
|
|
: s === PeDisplayStatus.DaDuyet
|
|
? String(PurchaseEvaluationPhase.DaDuyet)
|
|
: s === PeDisplayStatus.TraLai
|
|
? String(PurchaseEvaluationPhase.TraLai)
|
|
: s === PeDisplayStatus.TuChoi
|
|
? String(PurchaseEvaluationPhase.TuChoi)
|
|
: '' // DaGuiDuyet — multi-phase, không filter exact (TODO BE)
|
|
return phaseValue ? (
|
|
<option key={s} value={phaseValue}>{PeDisplayStatusLabel[s]}</option>
|
|
) : null
|
|
})}
|
|
</Select>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
{list.isLoading && (
|
|
<div className="space-y-2 p-3">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
|
|
))}
|
|
</div>
|
|
)}
|
|
{!list.isLoading && projectGroups.length === 0 && (
|
|
<div className="p-6">
|
|
<EmptyState icon={ClipboardCheck} title="Chưa có phiếu" description="Tạo phiếu mới để bắt đầu quy trình." />
|
|
</div>
|
|
)}
|
|
{/* Plan AG5 — Tree view 3-level Project > Năm > NCC > PE (anh feedback 2026-05-21:
|
|
"Folder cấp dưới dự án là theo năm và dưới năm là theo NCC").
|
|
3 layer <details>: 📁 Project (bg-slate-50) > 📅 Năm > 🏢 NCC > PE card.
|
|
Tailwind v3 named groups group/proj + group/year + group/sup cho chevron rotation. */}
|
|
<div className="divide-y divide-slate-100">
|
|
{projectGroups.map(pg => {
|
|
const projKey = pg.projectId ?? '__no_project__'
|
|
return (
|
|
<details
|
|
key={projKey}
|
|
open={isExpanded(projKey)}
|
|
onToggle={e => toggleExpand(projKey, (e.currentTarget as HTMLDetailsElement).open)}
|
|
className="group/proj"
|
|
>
|
|
<summary className="flex cursor-pointer items-center gap-1.5 bg-slate-50 px-3 py-2 hover:bg-slate-100 [&::-webkit-details-marker]:hidden">
|
|
<svg className="h-3 w-3 shrink-0 text-slate-500 transition-transform group-open/proj:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
|
<span className="text-base">📁</span>
|
|
<span className="flex-1 truncate text-[13px] font-medium text-slate-900">{pg.projectName}</span>
|
|
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-medium text-slate-700">{pg.totalCount}</span>
|
|
</summary>
|
|
<div className="ml-3 border-l border-slate-200">
|
|
{pg.years.map(yg => {
|
|
const yearKey = `${projKey}::y${yg.year}`
|
|
return (
|
|
<details
|
|
key={yearKey}
|
|
open={isExpanded(yearKey)}
|
|
onToggle={e => toggleExpand(yearKey, (e.currentTarget as HTMLDetailsElement).open)}
|
|
className="group/year"
|
|
>
|
|
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
|
|
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/year:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
|
<span className="text-sm">📅</span>
|
|
<span className="flex-1 truncate text-[12px] font-medium text-slate-700">Năm {yg.year}</span>
|
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{yg.totalCount}</span>
|
|
</summary>
|
|
<div className="ml-3 border-l border-slate-200">
|
|
{yg.suppliers.map(sg => {
|
|
const supKey = `${yearKey}::s${sg.supplierId ?? '_none_'}`
|
|
return (
|
|
<details
|
|
key={supKey}
|
|
open={isExpanded(supKey)}
|
|
onToggle={e => toggleExpand(supKey, (e.currentTarget as HTMLDetailsElement).open)}
|
|
className="group/sup"
|
|
>
|
|
<summary className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 hover:bg-slate-50 [&::-webkit-details-marker]:hidden">
|
|
<svg className="h-3 w-3 shrink-0 text-slate-400 transition-transform group-open/sup:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
|
<span className="text-sm">🏢</span>
|
|
<span className={cn('flex-1 truncate text-[12px]', sg.supplierId ? 'text-slate-700' : 'italic text-slate-400')}>{sg.supplierName}</span>
|
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">{sg.items.length}</span>
|
|
</summary>
|
|
<ul className="ml-3 divide-y divide-slate-100 border-l border-slate-200">
|
|
{sg.items.map(p => (
|
|
<li key={p.id}>
|
|
<button
|
|
onClick={() => selectRow(p.id)}
|
|
className={cn(
|
|
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
|
|
selectedId === p.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
|
|
)}
|
|
>
|
|
<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>
|
|
</div>
|
|
{(p.drafterName || p.departmentName) && (
|
|
<div className="mt-0.5 truncate text-[11px] text-slate-500">
|
|
<span>👤 {p.drafterName ?? '—'}</span>
|
|
{p.departmentName && <span className="text-slate-400"> · {p.departmentName}</span>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
|
PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
|
|
)}
|
|
>
|
|
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
|
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
|
|
{PurchaseEvaluationTypeLabel[p.type]}
|
|
</span>
|
|
{/* S23 t2 UAT: BE list sort theo UpdatedAt DESC (fallback CreatedAt) —
|
|
phiếu vừa update (Tạo / Gửi duyệt / Trả lại) đưa lên đầu list. */}
|
|
<span className="font-medium text-slate-600" title={`Tạo lúc ${new Date(p.createdAt).toLocaleString('vi-VN')}`}>
|
|
{new Date(p.createdAt).toLocaleString('vi-VN', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})}
|
|
</span>
|
|
</div>
|
|
{p.contractId && (
|
|
<div className="mt-1 text-[10px] text-brand-600">✓ Đã tạo HĐ</div>
|
|
)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</details>
|
|
)
|
|
})}
|
|
</div>
|
|
</details>
|
|
)
|
|
})}
|
|
</div>
|
|
</details>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Panel 2: Detail tabs */}
|
|
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
|
|
{!selectedId && (
|
|
<EmptyState icon={ClipboardCheck} title="Chọn phiếu ở danh sách" description="Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây." />
|
|
)}
|
|
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
|
{selectedId && detail.data && (
|
|
<PeDetailTabs
|
|
evaluation={detail.data}
|
|
onBack={() => setParam('id', null)}
|
|
onDelete={() => del.mutate(detail.data!.id)}
|
|
readOnly={true}
|
|
/>
|
|
)}
|
|
</main>
|
|
|
|
{/* Panel 3: Workflow + history */}
|
|
{/* Danh sách (pendingMe=false) → readOnly=true → ẩn Chuyển tiếp transition.
|
|
Duyệt (pendingMe=true) → readOnly=false → cho approver chuyển phase. */}
|
|
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
|
|
{!selectedId && (
|
|
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
|
|
<X className="mx-auto mb-2 h-5 w-5" />
|
|
Quy trình duyệt sẽ hiện khi chọn phiếu.
|
|
</div>
|
|
)}
|
|
{selectedId && detail.data && <PeWorkflowPanel evaluation={detail.data} readOnly={!pendingMe} />}
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Fullpage detail route cho mobile (/purchase-evaluations/:id)
|
|
export function PurchaseEvaluationDetailPage() {
|
|
const navigate = useNavigate()
|
|
const id = location.pathname.split('/').pop()!
|
|
const detail = useQuery({
|
|
queryKey: ['pe-detail', id],
|
|
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${id}`)).data,
|
|
})
|
|
const del = useMutation({
|
|
mutationFn: async () => api.delete(`/purchase-evaluations/${id}`),
|
|
onSuccess: () => {
|
|
toast.success('Đã xóa.')
|
|
navigate('/purchase-evaluations')
|
|
},
|
|
})
|
|
if (detail.isLoading) return <div className="p-6 text-sm text-slate-500">Đang tải…</div>
|
|
if (!detail.data) return <div className="p-6 text-sm text-red-600">Không tìm thấy phiếu.</div>
|
|
return (
|
|
<div className="space-y-4 p-6">
|
|
<PeDetailTabs evaluation={detail.data} onBack={() => navigate('/purchase-evaluations')} onDelete={() => del.mutate()} />
|
|
<PeWorkflowPanel evaluation={detail.data} />
|
|
</div>
|
|
)
|
|
}
|
|
|