[CLAUDE] PurchaseEvaluation: cờ gấp PRO/CCM + CCM duyệt-final theo ngưỡng giá trị (Mig 53) + 14 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m41s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m41s
Yêu cầu anh Kiệt FDC (sau họp sếp). Mig 53 AddPeUrgentAndCeoApprovalThreshold — 3 AddColumn, no new table (Mig 52→53). Rollout an toàn: cột nullable, ngưỡng null = giữ luồng duyệt cũ 100% cho tới khi admin set.
B — CCM duyệt-final theo NGƯỠNG GIÁ TRỊ ("gói CEO phân quyền theo giá trị"):
- ApprovalWorkflow += CeoApprovalThreshold (decimal?, admin nhập trong Workflow Designer).
- ApproveV2Async: actor role CostControl (CCM) + winnerQuoteTotal (tổng giá NCC được chọn) < ngưỡng → DaDuyet luôn (bỏ CEO); ≥ ngưỡng → đẩy lên CEO như cũ. Ngưỡng null = luồng tuyến tính cũ. Q4 chốt nhận diện theo ROLE người duyệt.
- reviewer PASS 0 blocker: cascade-safe (Off/role không lan), tested load-bearing (CCM dưới ngưỡng → DaDuyet skip CEO).
A — cờ gấp per-vai (visibility-only, Q3 KHÔNG đổi luồng):
- PE += IsUrgentByPro (PRO đỏ) / IsUrgentByCcm (CCM xanh).
- Endpoint PUT /purchase-evaluations/{id}/urgent role-gated (Procurement→ByPro, CostControl→ByCcm, Admin→cả 2, khác→Forbidden) + notify CEO (Director) khi MỚI bật (best-effort).
FE ×2 app: Workflow Designer ô "Ngưỡng giá trị gói CEO" (fe-admin) + PE detail nút bật/tắt cờ gấp đỏ/xanh theo role + badge GẤP + hint "giá trị gói vs ngưỡng → CCM duyệt-final/cần CEO" + PE list badge gấp.
DTO: PE detail += isUrgentByPro/Ccm + winnerQuoteTotal + ceoApprovalThreshold; list += isUrgentByPro/Ccm; workflow V2 += ceoApprovalThreshold.
+14 test (292→306): PeCcmThresholdFinalizeTests 5 (B routing) + PeUrgentToggleAuthzTests 9 (A authz). Build slnx 0/0 · npm build ×2 0 err · dotnet test 306 PASS.
C (sau duyệt xong chuyển phiếu đến dự án) — chờ anh Kiệt làm chi tiết form, CHƯA làm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -121,6 +121,13 @@ export function PeDetailTabs({
|
||||
// Mig 28 (S21 t4) — F3: Approver edit Section 2 (Hạng mục + NCC + Báo giá).
|
||||
const { user: currentUser } = useAuth()
|
||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||
// S69 — cờ gấp: role quyết định nút nào hiện. PRO (Procurement) → cờ ĐỎ
|
||||
// (isUrgentByPro), CCM (CostControl) → cờ XANH (isUrgentByCcm), Admin → cả 2.
|
||||
// BE chặn Forbidden role khác → FE chỉ ẩn nút (UX), không phải security.
|
||||
const isPro = currentUser?.roles?.includes('Procurement') ?? false
|
||||
const isCcm = currentUser?.roles?.includes('CostControl') ?? false
|
||||
const canToggleProUrgent = isAdmin || isPro
|
||||
const canToggleCcmUrgent = isAdmin || isCcm
|
||||
const v2Approvers = evaluation.currentApproval?.approvers ?? []
|
||||
const actorMatchesLevel = isAdmin
|
||||
|| (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id))
|
||||
@ -155,6 +162,19 @@ export function PeDetailTabs({
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
// S69 — toggle cờ gấp (PUT /urgent { isUrgent }). BE role-aware: PRO flip cờ ĐỎ,
|
||||
// CCM flip cờ XANH, Admin set CẢ 2. FE optimistic + invalidate detail + list.
|
||||
const toggleUrgent = useMutation({
|
||||
mutationFn: async (isUrgent: boolean) =>
|
||||
api.put(`/purchase-evaluations/${evaluation.id}/urgent`, { isUrgent }),
|
||||
onSuccess: (_d, isUrgent) => {
|
||||
toast.success(isUrgent ? 'Đã đánh dấu phiếu GẤP.' : 'Đã bỏ đánh dấu gấp.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const forwardPhase = evaluation.workflow.nextPhases.find(p =>
|
||||
p !== PurchaseEvaluationPhase.TuChoi && p !== PurchaseEvaluationPhase.TraLai)
|
||||
|
||||
@ -223,6 +243,17 @@ export function PeDetailTabs({
|
||||
<span className="text-[10px] text-slate-400" title="Phase workflow chi tiết">
|
||||
({PurchaseEvaluationPhaseLabel[evaluation.phase]})
|
||||
</span>
|
||||
{/* S69 — badge cờ gấp: ĐỎ (PRO) / XANH-lá (CCM). Hiển thị độc lập. */}
|
||||
{evaluation.isUrgentByPro && (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-[11px] font-semibold text-red-700" title="Phòng Cung ứng (PRO) đánh dấu gấp">
|
||||
🔴 GẤP (PRO)
|
||||
</span>
|
||||
)}
|
||||
{evaluation.isUrgentByCcm && (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-[11px] font-semibold text-green-700" title="Phòng Kiểm soát chi phí (CCM) đánh dấu gấp">
|
||||
🟢 GẤP (CCM)
|
||||
</span>
|
||||
)}
|
||||
{readOnly && (
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600">
|
||||
chế độ duyệt
|
||||
@ -239,6 +270,56 @@ export function PeDetailTabs({
|
||||
{evaluation.workItemName && <><span>–</span><span>{evaluation.workItemName}</span></>}
|
||||
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
|
||||
</div>
|
||||
{/* S69 — nút bật/tắt cờ gấp (theo role) + hint giá trị gói vs ngưỡng CEO. */}
|
||||
{(canToggleProUrgent || canToggleCcmUrgent || evaluation.ceoApprovalThreshold != null) && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
{canToggleProUrgent && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={toggleUrgent.isPending}
|
||||
onClick={() => toggleUrgent.mutate(!evaluation.isUrgentByPro)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-medium transition disabled:opacity-50',
|
||||
evaluation.isUrgentByPro
|
||||
? 'border-red-300 bg-red-50 text-red-700 hover:bg-red-100'
|
||||
: 'border-slate-300 bg-white text-slate-600 hover:border-red-300 hover:text-red-700',
|
||||
)}
|
||||
title="Cờ ĐỎ — Phòng Cung ứng (PRO) đánh dấu gấp"
|
||||
>
|
||||
🔴 {evaluation.isUrgentByPro ? 'Bỏ gấp (PRO)' : 'Đánh dấu GẤP (PRO)'}
|
||||
</button>
|
||||
)}
|
||||
{canToggleCcmUrgent && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={toggleUrgent.isPending}
|
||||
onClick={() => toggleUrgent.mutate(!evaluation.isUrgentByCcm)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-medium transition disabled:opacity-50',
|
||||
evaluation.isUrgentByCcm
|
||||
? 'border-green-300 bg-green-50 text-green-700 hover:bg-green-100'
|
||||
: 'border-slate-300 bg-white text-slate-600 hover:border-green-300 hover:text-green-700',
|
||||
)}
|
||||
title="Cờ XANH — Phòng Kiểm soát chi phí (CCM) đánh dấu gấp"
|
||||
>
|
||||
🟢 {evaluation.isUrgentByCcm ? 'Bỏ gấp (CCM)' : 'Đánh dấu GẤP (CCM)'}
|
||||
</button>
|
||||
)}
|
||||
{/* Hint giá trị gói vs ngưỡng CEO (chỉ khi workflow có set ngưỡng). */}
|
||||
{evaluation.ceoApprovalThreshold != null && (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-slate-50 px-2 py-1 text-[11px] text-slate-600">
|
||||
Giá trị gói: <strong className="text-slate-800">{fmtMoney(evaluation.winnerQuoteTotal)}đ</strong>
|
||||
{' — '}
|
||||
{evaluation.winnerQuoteTotal < evaluation.ceoApprovalThreshold ? (
|
||||
<span className="font-medium text-emerald-600">CCM duyệt là xong</span>
|
||||
) : (
|
||||
<span className="font-medium text-rose-600">Cần CEO duyệt</span>
|
||||
)}
|
||||
<span className="text-slate-400">(ngưỡng {fmtMoney(evaluation.ceoApprovalThreshold)}đ)</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Header bar actions: User 2026-05-07 chốt bỏ "Sửa header" + "Xóa" +
|
||||
"Đóng" (workspace mode actions chuyển xuống bottom action bar). Vẫn
|
||||
|
||||
@ -349,7 +349,12 @@ export function PurchaseEvaluationsListPage() {
|
||||
>
|
||||
{/* Plan AG6 — compact card 3 row: title+badge / mã+time / drafter+dept+contract */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-900">{p.tenGoiThau}</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 truncate text-[13px] font-medium text-slate-900">
|
||||
{/* S69 — chấm gấp: ĐỎ (PRO) / XANH-lá (CCM) cạnh tên gói. */}
|
||||
{p.isUrgentByPro && <span className="shrink-0 text-[11px]" title="GẤP — Phòng Cung ứng (PRO)">🔴</span>}
|
||||
{p.isUrgentByCcm && <span className="shrink-0 text-[11px]" title="GẤP — Phòng Kiểm soát chi phí (CCM)">🟢</span>}
|
||||
<span className="truncate">{p.tenGoiThau}</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
|
||||
@ -138,6 +138,9 @@ export type PeListItem = {
|
||||
// S61 — 2 cột ngân sách mới (list DTO mirror detail; chưa render ở list UI)
|
||||
budgetPeriodAmount: number | null
|
||||
expectedRemainingAmount: number | null
|
||||
// S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render chip nhỏ trên card list.
|
||||
isUrgentByPro: boolean
|
||||
isUrgentByCcm: boolean
|
||||
}
|
||||
|
||||
export type PeSupplier = {
|
||||
@ -434,6 +437,15 @@ export type PeDetailBundle = {
|
||||
budgetPeriodAmount: number | null // 'Ngân sách - kỳ này' (row 3 Excel) — drafter nhập
|
||||
expectedRemainingAmount: number | null // 'Giá trị thực hiện dự kiến còn lại' (row 8) — null = FE default NS còn lại
|
||||
budgetSummary: PeBudgetSummary | null // bảng TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (BE compute)
|
||||
// S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render badge header + toggle theo role.
|
||||
isUrgentByPro: boolean
|
||||
isUrgentByCcm: boolean
|
||||
// S69 — tổng giá chào của đơn vị NCC/TP ĐƯỢC CHỌN (winner). 0 khi chưa chọn.
|
||||
// FE so với ceoApprovalThreshold → hint "CCM duyệt là xong" / "Cần CEO duyệt".
|
||||
winnerQuoteTotal: number
|
||||
// S69 — ngưỡng gói CEO của workflow đã pin (PE.approvalWorkflowId). Null khi
|
||||
// chưa pin workflow V2 hoặc admin chưa set ngưỡng.
|
||||
ceoApprovalThreshold: number | null
|
||||
// Mig 23 — Pin schema mới ApprovalWorkflowsV2 (User chọn lúc create).
|
||||
approvalWorkflowId: string | null
|
||||
approvalWorkflowCode: string | null
|
||||
|
||||
Reference in New Issue
Block a user