[CLAUDE] PurchaseEvaluation: CV PRO refinement batch (anh Kiệt FDC) — live budget recompute + clear winner + fullscreen preview + hide create c/d
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m59s

C1 ẩn "c. Giá chào thầu" + "d. Bảng so sánh giá" khỏi form TẠO (vẫn hiện ở Detail tabs)
C3 ô 8 "Giá trị TH dự kiến còn lại" pre-fill từ dòng 7 (Ngân sách còn lại)
C4a live-recompute: VndInlineEdit +onLiveChange, PeBudgetSummaryTable draftRow3/8 → dòng 5/6/7/9 + So sánh + % nhảy NGAY khi gõ ô 3/8 (chưa cần Lưu)
C4b So sánh = 0 → "Bằng ngân sách" / "—" thay "0 đ" (chỉ khi base>0)
C5 hủy/đổi NCC winner: BE SelectWinnerBody(Guid?) + Command/Handler 2 nhánh (null=clear bỏ-qua-validate-list / non-null=giữ logic cũ); FE dropdown ""→clear + nút ✓ toggle. KHÔNG migration (SelectedSupplierId đã Guid?)
C7 nút "Toàn màn hình" preview file báo giá (overlay inset-0 + Esc thoát)

BE 2 file (Api controller record + Application command/handler). FE 3 file × 2 app SHA-identical. Test +10 PeSelectWinnerClearTests (4 clear + 4 select-regression + 2 PE-existence) → 366 PASS. Both FE build PASS, slnx build 0 err.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-23 08:47:51 +07:00
parent 0e159908d6
commit 5a6125494b
9 changed files with 546 additions and 124 deletions

View File

@ -907,10 +907,10 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
const setWinner = useMutation({
mutationFn: async (supplierId: string) =>
mutationFn: async (supplierId: string | null) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
onSuccess: () => {
toast.success('Đã chọn NCC.')
toast.success('Đã cập nhật NCC được chọn.')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
@ -934,7 +934,7 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
<div className="relative min-w-0 flex-1">
<Select
value={ev.selectedSupplierId ?? ''}
onChange={e => setWinner.mutate(e.target.value)}
onChange={e => setWinner.mutate(e.target.value || null)}
disabled={ev.suppliers.length === 0 || setWinner.isPending}
className="text-sm"
>
@ -977,32 +977,41 @@ const fmtVndSigned = (v: number) =>
v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
const fmtPct = (num: number, denom: number): string | null =>
denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : null
// [C4b anh Kiệt FDC] Dòng "So sánh" = 0 (đề xuất ĐÚNG BẰNG ngân sách) → chữ thay "0 đ"
// cho đỡ khó hiểu. base>0 (có ngân sách thật) → "Bằng ngân sách"; base<=0 (phiếu trống) → "—".
const fmtCompareValue = (v: number, base: number): React.ReactNode =>
v === 0
? <span className="font-sans text-[12px] font-normal text-slate-500">{base > 0 ? 'Bằng ngân sách' : '—'}</span>
: <span className={cn(v < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(v)}</span>
// Inline-edit số tiền VND (reuse formatVndInput/parseVnd module-level). allowNegative
// cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
function VndInlineEdit({
initial, allowNegative = false, onSave, saving, label,
initial, allowNegative = false, onSave, saving, label, onLiveChange,
}: {
initial: number | null
allowNegative?: boolean
onSave: (v: number | null) => void
saving: boolean
label?: string
/** [C4a anh Kiệt FDC] báo giá trị ĐANG GÕ lên cha mỗi keystroke → live-recompute. */
onLiveChange?: (v: number | null) => void
}) {
const [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
const [neg, setNeg] = useState((initial ?? 0) < 0)
const parse = (): number | null => {
const n = parseVnd(text)
if (n === 0 && text.trim() === '') return null
return allowNegative && neg ? -n : n
const valueOf = (raw: string, isNeg: boolean): number | null => {
const n = parseVnd(raw)
if (n === 0 && raw.trim() === '') return null
return allowNegative && isNeg ? -n : n
}
const parse = (): number | null => valueOf(text, neg)
const dirty = parse() !== initial
return (
<div className="flex items-center justify-end gap-1.5">
{allowNegative && (
<button
type="button"
onClick={() => setNeg(v => !v)}
onClick={() => { const nv = !neg; setNeg(nv); onLiveChange?.(valueOf(text, nv)) }}
className={cn(
'h-6 w-6 shrink-0 rounded border text-xs font-bold',
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
@ -1017,7 +1026,10 @@ function VndInlineEdit({
type="text"
inputMode="numeric"
value={text}
onChange={e => setText(formatVndInput(parseVnd(e.target.value)))}
onChange={e => {
setText(formatVndInput(parseVnd(e.target.value)))
onLiveChange?.(valueOf(e.target.value, neg))
}}
placeholder="0"
aria-label={label}
className="h-7 pr-6 font-mono text-right text-[13px]"
@ -1230,6 +1242,14 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
const [ccmNoteText, setCcmNoteText] = useState(bs?.ccmNote ?? '')
useEffect(() => { setCcmNoteText(bs?.ccmNote ?? '') }, [bs?.ccmNote])
// [C4a anh Kiệt FDC] Live-recompute: giữ giá trị ĐANG GÕ của ô 3 (NS kỳ này) + ô 8 (giá
// trị TH dự kiến còn lại) ở state cục bộ → dòng 5/6/7/9 + So sánh + % nhảy NGAY khi gõ
// (chưa cần bấm Lưu). Sync lại từ server (ev.*) sau mỗi save/refetch.
const [draftRow3, setDraftRow3] = useState<number | null>(ev.budgetPeriodAmount)
const [draftRow8, setDraftRow8] = useState<number | null>(ev.expectedRemainingAmount)
useEffect(() => { setDraftRow3(ev.budgetPeriodAmount) }, [ev.budgetPeriodAmount])
useEffect(() => { setDraftRow8(ev.expectedRemainingAmount) }, [ev.expectedRemainingAmount])
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
if (!bs) {
return (
@ -1249,12 +1269,12 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
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)
const row3 = draftRow3 ?? 0 // Ngân sách - kỳ này (drafter, live)
const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
const row5 = row1 + row3 // Lũy kế ngân sách đã sử dụng (= 1 + 3)
const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
const row7 = full - row5 // Ngân sách còn lại
const row8 = ev.expectedRemainingAmount ?? row7 // Giá trị thực hiện dự kiến còn lại
const row8 = draftRow8 ?? row7 // Giá trị thực hiện dự kiến còn lại (live)
const row9 = row4 + row8 // Giá trị tổng thực hiện dự kiến (= 4 + 8)
const cmpPeriod = row3 - row4 // So sánh với ngân sách kỳ này (row3 row4)
@ -1262,8 +1282,8 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
const cmpFull = full - row9 // So sánh với Ngân sách full (full row9)
// Cờ tô màu cảnh báo
const proposalOver = bs.currentProposalTotal > (ev.budgetPeriodAmount ?? 0) && ev.budgetPeriodAmount != null
const remainingOver = ev.expectedRemainingAmount != null && ev.expectedRemainingAmount > row7
const proposalOver = bs.currentProposalTotal > (draftRow3 ?? 0) && draftRow3 != null
const remainingOver = draftRow8 != null && draftRow8 > row7
return (
<div className="overflow-hidden rounded-lg border border-slate-300">
@ -1417,6 +1437,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
initial={ev.budgetPeriodAmount}
saving={adjustMut.isPending}
label="Ngân sách kỳ này"
onLiveChange={setDraftRow3}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
/>
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400"></span>
@ -1449,7 +1470,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
label="So sánh với ngân sách kỳ này"
indent
sub="= 3 4"
value={<span className={cn(cmpPeriod < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmpPeriod)}</span>}
value={fmtCompareValue(cmpPeriod, row3)}
third={fmtPct(cmpPeriod, row3) ?? undefined}
danger={cmpPeriod < 0}
/>
@ -1471,7 +1492,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
label="So với NS"
indent
sub="= 5 6"
value={<span className={cn(cmp56 < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmp56)}</span>}
value={fmtCompareValue(cmp56, row5)}
third={fmtPct(cmp56, row5) ?? undefined}
danger={cmp56 < 0}
/>
@ -1496,10 +1517,11 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
<div className="w-48 shrink-0 text-right">
{drafterEditable ? (
<VndInlineEdit
initial={ev.expectedRemainingAmount}
initial={ev.expectedRemainingAmount ?? row7}
allowNegative
saving={adjustMut.isPending}
label="Giá trị thực hiện dự kiến còn lại"
onLiveChange={setDraftRow8}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
/>
) : (
@ -1523,7 +1545,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
label="So sánh với Ngân sách full"
indent
sub="= Ngân sách full 9"
value={<span className={cn(cmpFull < 0 && 'font-bold text-red-600')}>{fmtVndSigned(cmpFull)}</span>}
value={fmtCompareValue(cmpFull, full)}
third={fmtPct(cmpFull, full) ?? undefined}
danger={cmpFull < 0}
/>
@ -2383,7 +2405,7 @@ function HangMucCard({
onError: e => toast.error(getErrorMessage(e)),
})
const setWinner = useMutation({
mutationFn: async (supplierId: string) =>
mutationFn: async (supplierId: string | null) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
@ -2552,12 +2574,12 @@ function HangMucCard({
<td className="px-2 py-1.5">
<div className="flex justify-end gap-0.5">
<button
onClick={() => setWinner.mutate(s.supplierId)}
onClick={() => setWinner.mutate(isWinner ? null : s.supplierId)}
className={cn(
'rounded px-1 py-0.5',
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
)}
title={isWinner ? 'Đơn vị NCC/TP đã được chọn' : 'Chọn đơn vị NCC/TP'}
title={isWinner ? 'Bỏ chọn đơn vị này (click để hủy)' : 'Chọn đơn vị NCC/TP'}
>
<Check className="h-3 w-3" />
</button>