[CLAUDE] PurchaseEvaluation · FE-User · FE-Admin · Tests: PE note "Duyệt là KẾT THÚC quy trình (không trình CEO)" — 3 surface
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m58s

Phiếu mà quy trình duyệt có cấp bật AllowApproverFinalize (Mig 58) sẽ kết thúc tại cấp đó,
KHÔNG trình CEO. Hiển thị note cho MỌI NGƯỜI NẮM ở 3 surface (anh Kiệt FDC). KHÔNG migration
(cột AllowApproverFinalize đã có Mig 58, prod ở Mig 59).

BE (3 file, computed DTO):
- PurchaseEvaluationDtos.cs: +bool EndsBeforeCeo +string? FinalizeStepName +string? FinalizeLevelName
  trên DetailBundle + ListItem; flow-level +bool AllowApproverFinalize.
- PurchaseEvaluationFeatures.cs: detail compute first-reachable (Step.Order↑→Level.Order↑, dept-name
  in-memory); list + inbox projection qua 3 subquery EF-safe (lv.Step!.Name). V1 (workflowId==null) → false.
- CreateContractFromEvaluationFeatures.cs: ListApproved projection cùng DTO → +3 subquery y hệt.

FE 2-app (fe-user + fe-admin SHA-mirror component):
- PeFinalizeChip.tsx (MỚI, chip tím) ở 3 danh sách: PeListPanel + PurchaseEvaluationsListPage (cây) + InboxPage.
- PeWorkflowPanel.tsx: note callout trong phiếu, branch DaDuyet (past-tense) / đang-chạy; copy boundary-aware
  "...là KẾT THÚC, không trình CEO. Các cấp trước vẫn duyệt như thường." (tránh cấp giữa hiểu nhầm).
- UserDashboardPage.tsx (chỉ fe-user): dải "Phiếu Duyệt NCC gần đây" + chip (admin dashboard để nguyên).
- types: PeListItem/PeDetailBundle/PeApprovalFlowLevel +field.

Tests: +PeFinalizeProjectionTests (EF-translate smoke 3 subquery + first-reachable + V1-false). Full suite 421→422.
em-main self-gate sau workflow (impl-frontend return rỗng #53 → mirror fe-admin + verify 5 site recover-disk).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-25 14:58:54 +07:00
parent a71753bde8
commit f0bca7737c
16 changed files with 417 additions and 6 deletions

View File

@ -10,9 +10,17 @@ import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button'
import { useAuth } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import type { Paged } from '@/types/master'
import type { ContractListItem } from '@/types/contracts'
import { ContractTypeLabel } from '@/types/forms'
import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip'
import {
getPeDisplayStatus,
PeDisplayStatusColor,
PeDisplayStatusLabel,
type PeListItem,
} from '@/types/purchaseEvaluation'
type MyDashboard = {
draftsInProgress: number
@ -97,6 +105,14 @@ export function UserDashboardPage() {
(await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 5 } })).data,
})
// [S89 — anh Kiệt FDC] 5 Phiếu Duyệt NCC gần đây, kèm chip "Quy trình kết thúc"
// (PeFinalizeChip) để user nhìn nhanh phiếu nào duyệt-đến-đây-là-xong không lên CEO.
const recentPe = useQuery({
queryKey: ['my-pe-recent'],
queryFn: async () =>
(await api.get<Paged<PeListItem>>('/purchase-evaluations', { params: { page: 1, pageSize: 5 } })).data,
})
const s = stats.data
return (
@ -220,6 +236,74 @@ export function UserDashboardPage() {
))}
</ul>
</section>
{/* [S89 — anh Kiệt FDC] Phiếu Duyệt NCC gần đây — mỗi row có PhaseBadge (PE
display-status) + PeFinalizeChip ("quy trình kết thúc, không lên CEO") +
tên gói + mã phiếu. PE phase ≠ Contract phase nên dùng PE display-status pill. */}
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<header className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-violet-50 text-violet-600">
<Inbox className="h-4 w-4" />
</span>
Phiếu Duyệt NCC gần đây
</h2>
<Button variant="outline" onClick={() => navigate('/purchase-evaluations')}>
Xem tất cả
</Button>
</header>
{recentPe.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-14 animate-pulse rounded-md bg-slate-100" />
))}
</div>
)}
{!recentPe.isLoading && (recentPe.data?.items?.length ?? 0) === 0 && (
<div className="p-6">
<EmptyState
icon={Inbox}
title="Chưa có phiếu nào"
description="Phiếu Duyệt NCC gần đây sẽ hiển thị tại đây."
/>
</div>
)}
<ul className="divide-y divide-slate-100">
{recentPe.data?.items?.map(p => (
<li key={p.id}>
<button
onClick={() => navigate(`/purchase-evaluations/${p.id}`)}
className="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-slate-50"
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium text-slate-900">
{p.tenGoiThau}
</span>
<PeFinalizeChip endsBeforeCeo={p.endsBeforeCeo} finalizeStepName={p.finalizeStepName} />
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1 ring-inset ring-current/15',
PeDisplayStatusColor[getPeDisplayStatus(p.phase)],
)}
>
{PeDisplayStatusLabel[getPeDisplayStatus(p.phase)]}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
<span className="font-mono">{p.maPhieu ?? '—'}</span>
<span>·</span>
<span className="truncate">{p.projectName}</span>
</div>
</div>
</button>
</li>
))}
</ul>
</section>
</div>
)
}