[CLAUDE] PurchaseEvaluation: Mig 56 ngan sach MA TRAN 3 cot (Du an|PRO|CCM) + badge quyen NS theo role
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m57s

S76 (anh Kiet FDC + chi Tra Sol) — form ngan sach + hien thi quyen nhap NS trong flow:
- Part 1: form ngan sach -> MA TRAN 3 cot, moi phong nhap+dieu chinh cot minh
  (PRO canEditPro / CCM canEditCcm / Du an FE hien-thi-only). Mig 56
  +ProInitialAmount/ProAdjustmentAmount (additive-nullable + data-migrate
  ProEstimate->ProInitial). full moi cot = ban hanh + hieu chinh.
- Part 2: Workflow Designer (fe-admin) +badge "NS PRO/CCM" canh approver
  (suy tu role Admin|Procurement / Admin|CostControl, hien-thi-only no-authz).
- Part 3: flow quy trinh fe-user/fe-admin (Duyet NCC) +badge tuong tu.
- Fix race mat-du-lieu Part 1 (useIsFetching khoa Luu khi refetch — dong
  cua-so stale-echo, reviewer Part2/3 bat).
- Test 339->344 (+5). 2 workflow review (Part 1 PASS + Part 2/3 PASS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-19 11:02:47 +07:00
parent 70c13d4ac8
commit e33481efb6
19 changed files with 6974 additions and 325 deletions

View File

@ -3,7 +3,7 @@
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
// → PeApprovalsSection + PeHistorySection).
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useIsFetching, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
@ -1057,9 +1057,106 @@ function BudgetBlockHeader({ children }: { children: React.ReactNode }) {
)
}
// [S76] Ô tiền compact cho ma trận 3 cột — editable (input + nút Lưu) hoặc display.
// allowNegative cho "hiệu chỉnh tăng giảm" (nút ± đảo dấu). onSave nhận number|null.
function BudgetCell({ value, editable, allowNegative = false, saving, onSave }: {
value: number | null
editable: boolean
allowNegative?: boolean
saving: boolean
onSave: (v: number | null) => void
}) {
const [text, setText] = useState(value != null ? Math.abs(value).toLocaleString('vi-VN') : '')
const [neg, setNeg] = useState((value ?? 0) < 0)
useEffect(() => {
setText(value != null ? Math.abs(value).toLocaleString('vi-VN') : '')
setNeg((value ?? 0) < 0)
}, [value])
if (!editable) {
return value != null
? <span className={cn('font-mono text-[13px] tabular-nums', value < 0 && 'text-red-600')}>{fmtVndSigned(value)}</span>
: <span className="text-slate-300"></span>
}
const parse = (): number | null => {
const n = parseVnd(text)
if (n === 0 && text.trim() === '') return null
return allowNegative && neg ? -n : n
}
const dirty = parse() !== value
return (
<div className="flex items-center justify-end gap-1">
{allowNegative && (
<button
type="button"
onClick={() => setNeg(v => !v)}
className={cn('h-7 w-7 shrink-0 rounded border text-[11px] font-bold',
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400')}
title="Đảo dấu âm/dương"
>
{neg ? '' : '+'}
</button>
)}
<Input
type="text"
inputMode="numeric"
value={text}
onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))}
placeholder="0"
className="h-7 min-w-0 px-1.5 text-right font-mono text-[12px]"
/>
<Button onClick={() => onSave(parse())} disabled={!dirty || saving} className="h-7 shrink-0 px-2 text-[11px]">
{saving ? '…' : 'Lưu'}
</Button>
</div>
)
}
// [S76] Dòng ghi chú phòng (Ghi chú từ PRO / từ CCM) — Textarea editable hoặc text display.
function BudgetNoteRow({ label, editable, value, setValue, savedValue, saving, onSave }: {
label: string
editable: boolean
value: string
setValue: (v: string) => void
savedValue: string | null
saving: boolean
onSave: () => void
}) {
return (
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
<div className="min-w-0 flex-1 text-slate-700">{label}</div>
<div className="w-72 shrink-0">
{editable ? (
<div className="space-y-1">
<textarea
value={value}
onChange={e => setValue(e.target.value)}
placeholder="Ghi chú…"
rows={2}
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
/>
<div className="flex justify-end">
<Button onClick={onSave} disabled={value === (savedValue ?? '') || saving} className="h-6 px-2 text-[11px]">
{saving ? '…' : 'Lưu ghi chú'}
</Button>
</div>
</div>
) : (
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
{savedValue || <span className="text-slate-400"></span>}
</div>
)}
</div>
</div>
)
}
function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const qc = useQueryClient()
const bs = ev.budgetSummary
// [S76] Khoá nút Lưu trong lúc pe-detail đang refetch (sau mỗi save) — đóng cửa-sổ
// stale-echo: tránh lưu 1 ô khi bs (server snapshot) chưa cập nhật → đè field anh-em
// (vd lưu PRO ban hành xong, lưu PRO hiệu chỉnh ngay sẽ echo bs.proInitial CŨ).
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
// Drafter nhập được row3 (NS kỳ này) + row8 (giá trị thực hiện dự kiến còn lại)
// khi phiếu DangSoanThao/TraLai + !readOnly. Mirror predicate row3/row8 spec.
@ -1070,9 +1167,9 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
qc.invalidateQueries({ queryKey: ['pe-list'] })
}
// PUT /budget/pro — chỉ khi canEditPro. proEstimateAmount + proNote.
// PUT /budget/pro — chỉ khi canEditPro. [S76] proInitial + proAdjust + proNote.
const proMut = useMutation({
mutationFn: async (body: { proEstimateAmount: number | null; proNote: string | null }) =>
mutationFn: async (body: { proInitialAmount: number | null; proAdjustmentAmount: number | null; proNote: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/budget/pro`, body),
onSuccess: () => { toast.success('Đã lưu ngân sách PRO'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
@ -1112,6 +1209,12 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
// ===== Số liệu Excel =====
const full = bs.fullAmount
// [S76] Full mỗi cột (ma trận A) = ban hành + hiệu chỉnh của cột đó.
const proFull = (bs.proInitialAmount ?? 0) + (bs.proAdjustmentAmount ?? 0)
const ccmFull = (bs.initialAmount ?? 0) + (bs.adjustmentAmount ?? 0)
// Cột "có dữ liệu" = đã nhập ban hành HOẶC hiệu chỉnh → hiện full (kể cả 0/âm); else "—".
const proHasData = bs.proInitialAmount != null || bs.proAdjustmentAmount != null
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu
const row3 = ev.budgetPeriodAmount ?? 0 // Ngân sách - kỳ này (drafter)
@ -1149,133 +1252,98 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
</div>
)}
{/* ===== Block A — NGÂN SÁCH (gói thầu) ===== */}
<BudgetBlockHeader>A. Ngân sách (gói thầu)</BudgetBlockHeader>
{/* ===== Block A — NGÂN SÁCH gói thầu: MA TRẬN 3 cột (DỰ ÁN | PRO | CCM) =====
[S76 anh Kiệt FDC] Mỗi phòng nhập+điều chỉnh cột của CHÍNH mình (role-gate):
PRO→cột PRO (canEditPro) · CCM→cột CCM (canEditCcm) · DỰ ÁN hiển thị-only
(chưa wire BE — sau có người dự án nhập). Full mỗi cột = ban hành + hiệu chỉnh. */}
<BudgetBlockHeader>A. Ngân sách (gói thầu / gói vật )</BudgetBlockHeader>
{/* Dòng 1 — Ngân sách (full gói thầu) — brand đậm */}
<BudgetRow
tone="brand"
label={
<span className="inline-flex items-center gap-2">
Ngân sách (full gói thầu)
{bs.fullIsEstimate && (
<span className="rounded bg-white/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase">ngân sách PRO</span>
)}
</span>
}
value={fmtVnd(full)}
/>
{/* Header 3 cột */}
<div className="flex items-center gap-2 border-b border-slate-200 bg-slate-50 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
<div className="min-w-0 flex-1" />
<div className="w-36 shrink-0 text-center">Dự án</div>
<div className="w-36 shrink-0 text-center">PRO</div>
<div className="w-36 shrink-0 text-center">CCM</div>
</div>
{/* Dòng 2Ban hành lần đầu (CCM editable) */}
<BudgetRow
label="Ngân sách Ban hành lần đầu"
value={
bs.canEditCcm ? (
<VndInlineEdit
initial={bs.initialAmount}
saving={ccmMut.isPending}
label="Ngân sách ban hành lần đầu"
onSave={v => ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount, ccmNote: bs.ccmNote })}
/>
) : bs.initialAmount != null ? fmtVnd(bs.initialAmount) : <span className="text-slate-400"></span>
}
/>
{/* Dòng 3 — Hiệu chỉnh V0 tăng giảm (CCM editable, cho phép âm) */}
<BudgetRow
label="Ngân sách V0 / hiệu chỉnh tăng giảm"
value={
bs.canEditCcm ? (
<VndInlineEdit
initial={bs.adjustmentAmount}
allowNegative
saving={ccmMut.isPending}
label="Ngân sách hiệu chỉnh tăng giảm"
onSave={v => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v, ccmNote: bs.ccmNote })}
/>
) : bs.adjustmentAmount != null ? (
<span className={cn(bs.adjustmentAmount < 0 && 'text-red-600')}>{fmtVndSigned(bs.adjustmentAmount)}</span>
) : <span className="text-slate-400"></span>
}
/>
{/* Ghi chú từ CCM (CCM editable — Textarea, mirror Ghi chú từ PRO) — [Mig anh Kiệt FDC] */}
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
<div className="min-w-0 flex-1 text-slate-700">Ghi chú từ CCM</div>
<div className="w-72 shrink-0">
{bs.canEditCcm ? (
<div className="space-y-1">
<textarea
value={ccmNoteText}
onChange={e => setCcmNoteText(e.target.value)}
placeholder="Ghi chú ngân sách CCM…"
rows={2}
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
/>
<div className="flex justify-end">
<Button
onClick={() => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: bs.adjustmentAmount, ccmNote: ccmNoteText || null })}
disabled={ccmNoteText === (bs.ccmNote ?? '') || ccmMut.isPending}
className="h-6 px-2 text-[11px]"
>
{ccmMut.isPending ? '…' : 'Lưu ghi chú'}
</Button>
</div>
</div>
) : (
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
{bs.ccmNote || <span className="text-slate-400"></span>}
</div>
)}
{/* Dòng 1Ngân sách (full gói thầu) per cột = ban hành + hiệu chỉnh — brand-soft */}
<div className="flex items-center gap-2 border-b border-slate-100 bg-[#1F7DC1]/10 px-3 py-2 text-[13px]">
<div className="min-w-0 flex-1 font-semibold text-slate-800">Ngân sách (full gói thầu / gói vật )</div>
<div className="w-36 shrink-0 text-right text-slate-300"></div>
<div className="w-36 shrink-0 text-right font-mono font-bold tabular-nums text-slate-900">
{proHasData ? fmtVnd(proFull) : <span className="text-slate-300"></span>}
</div>
<div className="w-36 shrink-0 text-right font-mono font-bold tabular-nums text-slate-900">
{ccmHasData ? fmtVnd(ccmFull) : <span className="text-slate-300"></span>}
</div>
</div>
{/* Dòng 4Dự trù PRO (PRO editable) */}
<BudgetRow
label="Ngân sách PRO"
value={
bs.canEditPro ? (
<VndInlineEdit
initial={bs.proEstimateAmount}
saving={proMut.isPending}
label="Ngân sách PRO"
onSave={v => proMut.mutate({ proEstimateAmount: v, proNote: proNoteText || null })}
/>
) : bs.proEstimateAmount != null ? fmtVnd(bs.proEstimateAmount) : <span className="text-slate-400"></span>
}
/>
{/* Dòng 5 — Ghi chú từ PRO (PRO editable — Textarea) */}
<div className="flex items-start gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
<div className="min-w-0 flex-1 text-slate-700">Ghi chú từ PRO</div>
<div className="w-72 shrink-0">
{bs.canEditPro ? (
<div className="space-y-1">
<textarea
value={proNoteText}
onChange={e => setProNoteText(e.target.value)}
placeholder="Ghi chú dự trù…"
rows={2}
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
/>
<div className="flex justify-end">
<Button
onClick={() => proMut.mutate({ proEstimateAmount: bs.proEstimateAmount, proNote: proNoteText || null })}
disabled={proNoteText === (bs.proNote ?? '') || proMut.isPending}
className="h-6 px-2 text-[11px]"
>
{proMut.isPending ? '…' : 'Lưu ghi chú'}
</Button>
</div>
</div>
) : (
<div className="whitespace-pre-wrap text-right text-[12px] text-slate-600">
{bs.proNote || <span className="text-slate-400"></span>}
</div>
)}
{/* Dòng 2Ban hành lần đầu: Dự án (—) | PRO (canEditPro) | CCM (canEditCcm) */}
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
<div className="min-w-0 flex-1 text-slate-700">Ngân sách Ban hành lần đu</div>
<div className="w-36 shrink-0 text-right text-slate-300"></div>
<div className="w-36 shrink-0">
<BudgetCell
value={bs.proInitialAmount}
editable={bs.canEditPro}
saving={proMut.isPending || peFetching}
onSave={v => proMut.mutate({ proInitialAmount: v, proAdjustmentAmount: bs.proAdjustmentAmount, proNote: bs.proNote })}
/>
</div>
<div className="w-36 shrink-0">
<BudgetCell
value={bs.initialAmount}
editable={bs.canEditCcm}
saving={ccmMut.isPending || peFetching}
onSave={v => ccmMut.mutate({ initialAmount: v, adjustmentAmount: bs.adjustmentAmount, ccmNote: bs.ccmNote })}
/>
</div>
</div>
{/* Dòng 3 — V0 / hiệu chỉnh tăng giảm (cho phép ÂM): Dự án (—) | PRO | CCM */}
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-1.5 text-[13px]">
<div className="min-w-0 flex-1 text-slate-700">Ngân sách V0 / hiệu chỉnh tăng giảm</div>
<div className="w-36 shrink-0 text-right text-slate-300"></div>
<div className="w-36 shrink-0">
<BudgetCell
value={bs.proAdjustmentAmount}
editable={bs.canEditPro}
allowNegative
saving={proMut.isPending || peFetching}
onSave={v => proMut.mutate({ proInitialAmount: bs.proInitialAmount, proAdjustmentAmount: v, proNote: bs.proNote })}
/>
</div>
<div className="w-36 shrink-0">
<BudgetCell
value={bs.adjustmentAmount}
editable={bs.canEditCcm}
allowNegative
saving={ccmMut.isPending || peFetching}
onSave={v => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: v, ccmNote: bs.ccmNote })}
/>
</div>
</div>
{/* Ghi chú từ PRO + từ CCM (cuối block A, đúng thứ tự Excel anh Kiệt) */}
<BudgetNoteRow
label="Ghi chú từ PRO"
editable={bs.canEditPro}
value={proNoteText}
setValue={setProNoteText}
savedValue={bs.proNote}
saving={proMut.isPending || peFetching}
onSave={() => proMut.mutate({ proInitialAmount: bs.proInitialAmount, proAdjustmentAmount: bs.proAdjustmentAmount, proNote: proNoteText || null })}
/>
<BudgetNoteRow
label="Ghi chú từ CCM"
editable={bs.canEditCcm}
value={ccmNoteText}
setValue={setCcmNoteText}
savedValue={bs.ccmNote}
saving={ccmMut.isPending || peFetching}
onSave={() => ccmMut.mutate({ initialAmount: bs.initialAmount, adjustmentAmount: bs.adjustmentAmount, ccmNote: ccmNoteText || null })}
/>
{/* ===== Block B — THỰC HIỆN ===== */}
<BudgetBlockHeader>B. Thực hiện</BudgetBlockHeader>

View File

@ -240,8 +240,22 @@ export function PeWorkflowPanel({
{lv.status === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
{lv.status === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
</div>
<div className="text-slate-500">
{lv.approvers.map(a => a.fullName).join(' / ') || '(chưa cấu hình)'}
<div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-slate-500">
{lv.approvers.length === 0
? '(chưa cấu hình)'
: lv.approvers.map((a, i) => (
<span key={a.userId} className="inline-flex items-center gap-1">
{i > 0 && <span className="text-slate-300">/</span>}
<span>{a.fullName}</span>
{/* [S76] Badge quyền nhập/điều chỉnh ngân sách (hiển thị-only, suy từ role) */}
{a.canEditProBudget && (
<span className="rounded bg-amber-100 px-1 py-0.5 text-[8px] font-semibold text-amber-700" title="Được nhập/điều chỉnh ngân sách cột PRO"> NS PRO</span>
)}
{a.canEditCcmBudget && (
<span className="rounded bg-sky-100 px-1 py-0.5 text-[8px] font-semibold text-sky-700" title="Được nhập/điều chỉnh ngân sách cột CCM"> NS CCM</span>
)}
</span>
))}
</div>
</div>
</div>