[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

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Loader2, AlertTriangle } from 'lucide-react'
import { Loader2, AlertTriangle, Maximize2, Minimize2 } from 'lucide-react'
import { Dialog } from '@/components/ui/Dialog'
import { Button } from '@/components/ui/Button'
import { api } from '@/lib/api'
@ -24,17 +24,33 @@ type Props = {
/** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline).
* Fetch as blob → object URL → iframe (PDF) hoặc img (image).
* Bearer auth qua axios api client (KHÔNG thể set iframe src trực tiếp vì
* iframe không inherit Authorization header). */
* iframe không inherit Authorization header).
* [C7 anh Kiệt FDC] Nút "Toàn màn hình" → lớp phủ inset-0 phóng to preview hết
* cỡ viewport (Dialog dùng chung chỉ có sm/md/lg → tự render overlay riêng). */
export function AttachmentPreviewDialog({
open, evaluationId, attachmentId, fileName, onClose,
}: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [fullscreen, setFullscreen] = useState(false)
const ext = fileName.toLowerCase().split('.').pop() ?? ''
const isImage = ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(ext)
// Reset toàn-màn-hình mỗi khi đóng / đổi file.
useEffect(() => { if (!open) setFullscreen(false) }, [open])
// Esc khi đang toàn-màn-hình → thoát toàn-màn-hình TRƯỚC (không đóng luôn Dialog).
useEffect(() => {
if (!fullscreen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.stopPropagation(); setFullscreen(false) }
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [fullscreen])
useEffect(() => {
if (!open) return
let cancelled = false
@ -64,34 +80,65 @@ export function AttachmentPreviewDialog({
}
}, [open, evaluationId, attachmentId])
const ready = blobUrl && !loading && !error
return (
<Dialog
open={open}
onClose={onClose}
title={`Xem file: ${fileName}`}
size="lg"
footer={<Button variant="outline" onClick={onClose}>Đóng</Button>}
>
<div className="h-[70vh] w-full bg-slate-100">
{loading && (
<div className="flex h-full items-center justify-center gap-2 text-slate-500">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Đang tải file</span>
<>
<Dialog
open={open}
onClose={onClose}
title={`Xem file: ${fileName}`}
size="lg"
footer={
<>
<Button variant="outline" onClick={() => setFullscreen(true)} disabled={!ready}>
<Maximize2 className="mr-1 h-3.5 w-3.5" /> Toàn màn hình
</Button>
<Button variant="outline" onClick={onClose}>Đóng</Button>
</>
}
>
<div className="h-[70vh] w-full bg-slate-100">
{loading && (
<div className="flex h-full items-center justify-center gap-2 text-slate-500">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Đang tải file</span>
</div>
)}
{error && !loading && (
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center text-red-600">
<AlertTriangle className="h-8 w-8" />
<div className="font-medium">Không tải đưc file</div>
<div className="text-xs text-red-500">{error}</div>
</div>
)}
{ready && (
isImage
? <img src={blobUrl} alt={fileName} className="mx-auto h-full object-contain" />
: <iframe src={blobUrl} title={fileName} className="h-full w-full border-0" />
)}
</div>
</Dialog>
{/* [C7] Lớp phủ toàn-màn-hình — preview lớn hết cỡ viewport, trên cả Dialog. */}
{open && fullscreen && ready && (
<div className="fixed inset-0 z-[60] flex flex-col bg-black/95">
<div className="flex items-center justify-between gap-3 px-4 py-2 text-white">
<span className="truncate text-sm font-medium">{fileName}</span>
<button
onClick={() => setFullscreen(false)}
className="inline-flex shrink-0 items-center gap-1 rounded-md bg-white/10 px-3 py-1.5 text-xs hover:bg-white/20"
>
<Minimize2 className="h-4 w-4" /> Thu nhỏ (Esc)
</button>
</div>
)}
{error && !loading && (
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center text-red-600">
<AlertTriangle className="h-8 w-8" />
<div className="font-medium">Không tải đưc file</div>
<div className="text-xs text-red-500">{error}</div>
<div className="flex flex-1 items-center justify-center overflow-auto p-2">
{isImage
? <img src={blobUrl} alt={fileName} className="max-h-full max-w-full object-contain" />
: <iframe src={blobUrl} title={fileName} className="h-full w-full border-0 bg-white" />}
</div>
)}
{blobUrl && !loading && !error && (
isImage
? <img src={blobUrl} alt={fileName} className="mx-auto h-full object-contain" />
: <iframe src={blobUrl} title={fileName} className="h-full w-full border-0" />
)}
</div>
</Dialog>
</div>
)}
</>
)
}

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>

View File

@ -270,14 +270,8 @@ export function PeWorkspaceCreateView({
</div>
</div>
<FormRow
label="c. Giá chào thầu"
value={<span className="text-slate-400"> (auto-tính từ báo giá NCC sau khi chọn winner)</span>}
/>
<FormRow
label="d. Bảng so sánh giá"
value={<LockedHint text="Tải bảng so sánh sau khi tạo phiếu." />}
/>
{/* [C1 anh Kiệt FDC] "c. Giá chào thầu" + "d. Bảng so sánh giá" ẨN khỏi form
TẠO (chưa dùng được lúc tạo) — vẫn hiện đầy đủ ở Detail tabs sau khi tạo phiếu. */}
{/* e. Link hồ sơ (anh Kiệt FDC) — dán link thư mục hồ sơ trên NAS công ty
(1 cột HoSoLink). Create = Input; khi xem phiếu render thẻ <a> bấm-mở. */}