[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
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:
23
fe-user/src/components/pe/PeFinalizeChip.tsx
Normal file
23
fe-user/src/components/pe/PeFinalizeChip.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
// PE — chip "Quy trình kết thúc tại cấp này" (không trình CEO). Hiện khi quy
|
||||
// trình của phiếu có cấp bật AllowApproverFinalize (Mig 58, anh Kiệt FDC S89).
|
||||
// SINGLE SOURCE OF TRUTH cho pill này dùng ở MỌI danh sách phiếu (cây danh sách
|
||||
// + picker workspace/chờ-duyệt + inbox) để mọi người NẮM ngay "phiếu này duyệt
|
||||
// đến đây là xong, không lên CEO" mà KHÔNG cần mở phiếu. Shape khớp PeUrgentChips.
|
||||
|
||||
export function PeFinalizeChip({
|
||||
endsBeforeCeo,
|
||||
finalizeStepName,
|
||||
}: {
|
||||
endsBeforeCeo: boolean
|
||||
finalizeStepName: string | null
|
||||
}) {
|
||||
if (!endsBeforeCeo) return null
|
||||
return (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center rounded bg-violet-100 px-1.5 py-0.5 text-[11px] font-semibold text-violet-800"
|
||||
title={`Quy trình kết thúc tại ${finalizeStepName ?? 'cấp này'} — không trình CEO`}
|
||||
>
|
||||
{finalizeStepName ? `⚑ Kết thúc · ${finalizeStepName}` : '⚑ Không lên CEO'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -26,6 +26,7 @@ import {
|
||||
type PeListItem,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
import { PeUrgentChips } from '@/components/pe/PeUrgentChips'
|
||||
import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip'
|
||||
|
||||
export function PeListPanel({
|
||||
typeFilter,
|
||||
@ -158,6 +159,7 @@ export function PeListPanel({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1 text-[13px] font-medium text-slate-900">
|
||||
<PeUrgentChips isUrgentByPro={p.isUrgentByPro} isUrgentByCcm={p.isUrgentByCcm} />
|
||||
<PeFinalizeChip endsBeforeCeo={p.endsBeforeCeo} finalizeStepName={p.finalizeStepName} />
|
||||
<span className="truncate">{p.tenGoiThau}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
|
||||
@ -251,6 +251,29 @@ export function PeWorkflowPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* [Mig 58 AllowApproverFinalize — anh Kiệt FDC, S89] NOTE cho MỌI người NẮM:
|
||||
quy trình của phiếu này có cấp được cấu hình "Duyệt là KẾT THÚC" (không
|
||||
trình CEO). Branch theo phase: đang chạy = callout xanh-lá báo trước /
|
||||
Đã duyệt = xác nhận đã kết thúc tại đâu. */}
|
||||
{evaluation.endsBeforeCeo && (
|
||||
<div className="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
|
||||
{evaluation.phase === PurchaseEvaluationPhase.DaDuyet ? (
|
||||
<span>
|
||||
✅ Phiếu đã kết thúc tại{' '}
|
||||
<span className="font-semibold">{evaluation.finalizeStepName ?? 'cấp duyệt'}</span>
|
||||
{evaluation.finalizeLevelName ? <> · {evaluation.finalizeLevelName}</> : null} — không qua CEO.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
⚑ Quy trình rút gọn: duyệt đến{' '}
|
||||
<span className="font-semibold">{evaluation.finalizeStepName ?? 'cấp này'}</span>
|
||||
{evaluation.finalizeLevelName ? <> · {evaluation.finalizeLevelName}</> : null} là KẾT THÚC,
|
||||
không trình CEO. Các cấp trước vẫn duyệt như thường.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mig 24 V2 — Flow render Bước → Cấp → NV thay phase cards.
|
||||
Status: Done (✓ emerald) / Current (● brand) / Pending (○ slate) */}
|
||||
{flow && flow.steps.length > 0 && (
|
||||
|
||||
@ -26,6 +26,7 @@ import {
|
||||
type PeListItem,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
import { PeUrgentChips } from '@/components/pe/PeUrgentChips'
|
||||
import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip'
|
||||
|
||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||
|
||||
@ -288,6 +289,7 @@ export function InboxPage() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1 text-[13px] font-medium text-slate-900">
|
||||
<PeUrgentChips isUrgentByPro={p.isUrgentByPro} isUrgentByCcm={p.isUrgentByCcm} />
|
||||
<PeFinalizeChip endsBeforeCeo={p.endsBeforeCeo} finalizeStepName={p.finalizeStepName} />
|
||||
<span className="truncate">{p.tenGoiThau}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ import {
|
||||
} from '@/types/purchaseEvaluation'
|
||||
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
|
||||
import { PeUrgentChips } from '@/components/pe/PeUrgentChips'
|
||||
import { PeFinalizeChip } from '@/components/pe/PeFinalizeChip'
|
||||
import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel'
|
||||
|
||||
export function PurchaseEvaluationsListPage() {
|
||||
@ -487,6 +488,7 @@ export function PurchaseEvaluationsListPage() {
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 truncate text-[13px] font-medium text-slate-900">
|
||||
{/* S77 — cờ gấp PILL đồng bộ mọi danh sách: 🔴 GẤP (PRO) / 🟢 GẤP (CCM). */}
|
||||
<PeUrgentChips isUrgentByPro={p.isUrgentByPro} isUrgentByCcm={p.isUrgentByCcm} />
|
||||
<PeFinalizeChip endsBeforeCeo={p.endsBeforeCeo} finalizeStepName={p.finalizeStepName} />
|
||||
<span className="truncate">{p.tenGoiThau}</span>
|
||||
</div>
|
||||
<span
|
||||
|
||||
@ -145,6 +145,12 @@ export type PeListItem = {
|
||||
// null selectedSupplierName khi joint ≥2 winner → list card ưu tiên field này,
|
||||
// fallback selectedSupplierName cho phiếu 1-winner cũ.
|
||||
winnerSupplierNames: string[]
|
||||
// [Mig 58 AllowApproverFinalize — anh Kiệt FDC, S89] Quy trình có cấp bật "Duyệt là
|
||||
// KẾT THÚC" (không trình CEO). BE first-reachable finalize theo Step.Order↑ → Level.Order↑.
|
||||
// V1 (approvalWorkflowId==null) → endsBeforeCeo=false + names null. FE render PeFinalizeChip.
|
||||
endsBeforeCeo: boolean
|
||||
finalizeStepName: string | null
|
||||
finalizeLevelName: string | null
|
||||
}
|
||||
|
||||
export type PeSupplier = {
|
||||
@ -265,6 +271,8 @@ export type PeApprovalFlowLevel = {
|
||||
name: string | null
|
||||
approvers: PeCurrentApprovalLevelApprover[]
|
||||
status: 'Done' | 'Current' | 'Pending'
|
||||
// [Mig 58 — anh Kiệt FDC, S89] Cấp này bật "Duyệt là KẾT THÚC quy trình" (không trình CEO).
|
||||
allowApproverFinalize: boolean
|
||||
}
|
||||
|
||||
export type PeApprovalFlowStep = {
|
||||
@ -495,6 +503,12 @@ export type PeDetailBundle = {
|
||||
currentApproval: PeCurrentApproval | null
|
||||
// Mig 24 — full flow snapshot (Bước → Cấp → NV) với Status per level
|
||||
approvalFlow: PeApprovalFlow | null
|
||||
// [Mig 58 AllowApproverFinalize — anh Kiệt FDC, S89] Quy trình có cấp bật "Duyệt là KẾT
|
||||
// THÚC" (không trình CEO). BE first-reachable finalize theo Step.Order↑ → Level.Order↑
|
||||
// (dùng nơi flow THỰC kết thúc). V1 (approvalWorkflowId==null) → endsBeforeCeo=false, names null.
|
||||
endsBeforeCeo: boolean
|
||||
finalizeStepName: string | null
|
||||
finalizeLevelName: string | null
|
||||
suppliers: PeSupplier[]
|
||||
details: PeDetailRow[]
|
||||
approvals: PeApproval[]
|
||||
|
||||
Reference in New Issue
Block a user