[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 { 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 { Dialog } from '@/components/ui/Dialog'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -24,17 +24,33 @@ type Props = {
/** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline). /** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline).
* Fetch as blob → object URL → iframe (PDF) hoặc img (image). * 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ì * 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({ export function AttachmentPreviewDialog({
open, evaluationId, attachmentId, fileName, onClose, open, evaluationId, attachmentId, fileName, onClose,
}: Props) { }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null) const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [fullscreen, setFullscreen] = useState(false)
const ext = fileName.toLowerCase().split('.').pop() ?? '' const ext = fileName.toLowerCase().split('.').pop() ?? ''
const isImage = ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(ext) 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(() => { useEffect(() => {
if (!open) return if (!open) return
let cancelled = false let cancelled = false
@ -64,13 +80,23 @@ export function AttachmentPreviewDialog({
} }
}, [open, evaluationId, attachmentId]) }, [open, evaluationId, attachmentId])
const ready = blobUrl && !loading && !error
return ( return (
<>
<Dialog <Dialog
open={open} open={open}
onClose={onClose} onClose={onClose}
title={`Xem file: ${fileName}`} title={`Xem file: ${fileName}`}
size="lg" size="lg"
footer={<Button variant="outline" onClick={onClose}>Đóng</Button>} 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"> <div className="h-[70vh] w-full bg-slate-100">
{loading && ( {loading && (
@ -86,12 +112,33 @@ export function AttachmentPreviewDialog({
<div className="text-xs text-red-500">{error}</div> <div className="text-xs text-red-500">{error}</div>
</div> </div>
)} )}
{blobUrl && !loading && !error && ( {ready && (
isImage isImage
? <img src={blobUrl} alt={fileName} className="mx-auto h-full object-contain" /> ? <img src={blobUrl} alt={fileName} className="mx-auto h-full object-contain" />
: <iframe src={blobUrl} title={fileName} className="h-full w-full border-0" /> : <iframe src={blobUrl} title={fileName} className="h-full w-full border-0" />
)} )}
</div> </div>
</Dialog> </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>
<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>
</div>
)}
</>
) )
} }

View File

@ -907,10 +907,10 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
const canEdit = !readOnly && isEditablePhase(ev.phase) const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient() const qc = useQueryClient()
const setWinner = useMutation({ const setWinner = useMutation({
mutationFn: async (supplierId: string) => mutationFn: async (supplierId: string | null) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }), api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
onSuccess: () => { 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-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] }) 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"> <div className="relative min-w-0 flex-1">
<Select <Select
value={ev.selectedSupplierId ?? ''} 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} disabled={ev.suppliers.length === 0 || setWinner.isPending}
className="text-sm" 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')} đ` v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
const fmtPct = (num: number, denom: number): string | null => const fmtPct = (num: number, denom: number): string | null =>
denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : 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 // 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. // cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
function VndInlineEdit({ function VndInlineEdit({
initial, allowNegative = false, onSave, saving, label, initial, allowNegative = false, onSave, saving, label, onLiveChange,
}: { }: {
initial: number | null initial: number | null
allowNegative?: boolean allowNegative?: boolean
onSave: (v: number | null) => void onSave: (v: number | null) => void
saving: boolean saving: boolean
label?: string 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 [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
const [neg, setNeg] = useState((initial ?? 0) < 0) const [neg, setNeg] = useState((initial ?? 0) < 0)
const parse = (): number | null => { const valueOf = (raw: string, isNeg: boolean): number | null => {
const n = parseVnd(text) const n = parseVnd(raw)
if (n === 0 && text.trim() === '') return null if (n === 0 && raw.trim() === '') return null
return allowNegative && neg ? -n : n return allowNegative && isNeg ? -n : n
} }
const parse = (): number | null => valueOf(text, neg)
const dirty = parse() !== initial const dirty = parse() !== initial
return ( return (
<div className="flex items-center justify-end gap-1.5"> <div className="flex items-center justify-end gap-1.5">
{allowNegative && ( {allowNegative && (
<button <button
type="button" type="button"
onClick={() => setNeg(v => !v)} onClick={() => { const nv = !neg; setNeg(nv); onLiveChange?.(valueOf(text, nv)) }}
className={cn( className={cn(
'h-6 w-6 shrink-0 rounded border text-xs font-bold', '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', neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
@ -1017,7 +1026,10 @@ function VndInlineEdit({
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={text} 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" placeholder="0"
aria-label={label} aria-label={label}
className="h-7 pr-6 font-mono text-right text-[13px]" 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 ?? '') const [ccmNoteText, setCcmNoteText] = useState(bs?.ccmNote ?? '')
useEffect(() => { setCcmNoteText(bs?.ccmNote ?? '') }, [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. // Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
if (!bs) { if (!bs) {
return ( return (
@ -1249,12 +1269,12 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước 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 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 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 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 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 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 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) 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) const cmpFull = full - row9 // So sánh với Ngân sách full (full row9)
// Cờ tô màu cảnh báo // Cờ tô màu cảnh báo
const proposalOver = bs.currentProposalTotal > (ev.budgetPeriodAmount ?? 0) && ev.budgetPeriodAmount != null const proposalOver = bs.currentProposalTotal > (draftRow3 ?? 0) && draftRow3 != null
const remainingOver = ev.expectedRemainingAmount != null && ev.expectedRemainingAmount > row7 const remainingOver = draftRow8 != null && draftRow8 > row7
return ( return (
<div className="overflow-hidden rounded-lg border border-slate-300"> <div className="overflow-hidden rounded-lg border border-slate-300">
@ -1417,6 +1437,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
initial={ev.budgetPeriodAmount} initial={ev.budgetPeriodAmount}
saving={adjustMut.isPending} saving={adjustMut.isPending}
label="Ngân sách kỳ này" label="Ngân sách kỳ này"
onLiveChange={setDraftRow3}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })} onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
/> />
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400"></span> ) : 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" label="So sánh với ngân sách kỳ này"
indent indent
sub="= 3 4" 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} third={fmtPct(cmpPeriod, row3) ?? undefined}
danger={cmpPeriod < 0} danger={cmpPeriod < 0}
/> />
@ -1471,7 +1492,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
label="So với NS" label="So với NS"
indent indent
sub="= 5 6" 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} third={fmtPct(cmp56, row5) ?? undefined}
danger={cmp56 < 0} danger={cmp56 < 0}
/> />
@ -1496,10 +1517,11 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
<div className="w-48 shrink-0 text-right"> <div className="w-48 shrink-0 text-right">
{drafterEditable ? ( {drafterEditable ? (
<VndInlineEdit <VndInlineEdit
initial={ev.expectedRemainingAmount} initial={ev.expectedRemainingAmount ?? row7}
allowNegative allowNegative
saving={adjustMut.isPending} saving={adjustMut.isPending}
label="Giá trị thực hiện dự kiến còn lại" 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 })} 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" label="So sánh với Ngân sách full"
indent indent
sub="= Ngân sách full 9" 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} third={fmtPct(cmpFull, full) ?? undefined}
danger={cmpFull < 0} danger={cmpFull < 0}
/> />
@ -2383,7 +2405,7 @@ function HangMucCard({
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
const setWinner = useMutation({ const setWinner = useMutation({
mutationFn: async (supplierId: string) => mutationFn: async (supplierId: string | null) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }), 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] }) }, onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
@ -2552,12 +2574,12 @@ function HangMucCard({
<td className="px-2 py-1.5"> <td className="px-2 py-1.5">
<div className="flex justify-end gap-0.5"> <div className="flex justify-end gap-0.5">
<button <button
onClick={() => setWinner.mutate(s.supplierId)} onClick={() => setWinner.mutate(isWinner ? null : s.supplierId)}
className={cn( className={cn(
'rounded px-1 py-0.5', 'rounded px-1 py-0.5',
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700', 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" /> <Check className="h-3 w-3" />
</button> </button>

View File

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

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' 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 { Dialog } from '@/components/ui/Dialog'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@ -24,17 +24,33 @@ type Props = {
/** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline). /** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline).
* Fetch as blob → object URL → iframe (PDF) hoặc img (image). * 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ì * 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({ export function AttachmentPreviewDialog({
open, evaluationId, attachmentId, fileName, onClose, open, evaluationId, attachmentId, fileName, onClose,
}: Props) { }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null) const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [fullscreen, setFullscreen] = useState(false)
const ext = fileName.toLowerCase().split('.').pop() ?? '' const ext = fileName.toLowerCase().split('.').pop() ?? ''
const isImage = ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(ext) 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(() => { useEffect(() => {
if (!open) return if (!open) return
let cancelled = false let cancelled = false
@ -64,13 +80,23 @@ export function AttachmentPreviewDialog({
} }
}, [open, evaluationId, attachmentId]) }, [open, evaluationId, attachmentId])
const ready = blobUrl && !loading && !error
return ( return (
<>
<Dialog <Dialog
open={open} open={open}
onClose={onClose} onClose={onClose}
title={`Xem file: ${fileName}`} title={`Xem file: ${fileName}`}
size="lg" size="lg"
footer={<Button variant="outline" onClick={onClose}>Đóng</Button>} 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"> <div className="h-[70vh] w-full bg-slate-100">
{loading && ( {loading && (
@ -86,12 +112,33 @@ export function AttachmentPreviewDialog({
<div className="text-xs text-red-500">{error}</div> <div className="text-xs text-red-500">{error}</div>
</div> </div>
)} )}
{blobUrl && !loading && !error && ( {ready && (
isImage isImage
? <img src={blobUrl} alt={fileName} className="mx-auto h-full object-contain" /> ? <img src={blobUrl} alt={fileName} className="mx-auto h-full object-contain" />
: <iframe src={blobUrl} title={fileName} className="h-full w-full border-0" /> : <iframe src={blobUrl} title={fileName} className="h-full w-full border-0" />
)} )}
</div> </div>
</Dialog> </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>
<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>
</div>
)}
</>
) )
} }

View File

@ -907,10 +907,10 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
const canEdit = !readOnly && isEditablePhase(ev.phase) const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient() const qc = useQueryClient()
const setWinner = useMutation({ const setWinner = useMutation({
mutationFn: async (supplierId: string) => mutationFn: async (supplierId: string | null) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }), api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
onSuccess: () => { 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-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] }) 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"> <div className="relative min-w-0 flex-1">
<Select <Select
value={ev.selectedSupplierId ?? ''} 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} disabled={ev.suppliers.length === 0 || setWinner.isPending}
className="text-sm" 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')} đ` v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
const fmtPct = (num: number, denom: number): string | null => const fmtPct = (num: number, denom: number): string | null =>
denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : 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 // 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. // cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
function VndInlineEdit({ function VndInlineEdit({
initial, allowNegative = false, onSave, saving, label, initial, allowNegative = false, onSave, saving, label, onLiveChange,
}: { }: {
initial: number | null initial: number | null
allowNegative?: boolean allowNegative?: boolean
onSave: (v: number | null) => void onSave: (v: number | null) => void
saving: boolean saving: boolean
label?: string 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 [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
const [neg, setNeg] = useState((initial ?? 0) < 0) const [neg, setNeg] = useState((initial ?? 0) < 0)
const parse = (): number | null => { const valueOf = (raw: string, isNeg: boolean): number | null => {
const n = parseVnd(text) const n = parseVnd(raw)
if (n === 0 && text.trim() === '') return null if (n === 0 && raw.trim() === '') return null
return allowNegative && neg ? -n : n return allowNegative && isNeg ? -n : n
} }
const parse = (): number | null => valueOf(text, neg)
const dirty = parse() !== initial const dirty = parse() !== initial
return ( return (
<div className="flex items-center justify-end gap-1.5"> <div className="flex items-center justify-end gap-1.5">
{allowNegative && ( {allowNegative && (
<button <button
type="button" type="button"
onClick={() => setNeg(v => !v)} onClick={() => { const nv = !neg; setNeg(nv); onLiveChange?.(valueOf(text, nv)) }}
className={cn( className={cn(
'h-6 w-6 shrink-0 rounded border text-xs font-bold', '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', neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
@ -1017,7 +1026,10 @@ function VndInlineEdit({
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={text} 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" placeholder="0"
aria-label={label} aria-label={label}
className="h-7 pr-6 font-mono text-right text-[13px]" 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 ?? '') const [ccmNoteText, setCcmNoteText] = useState(bs?.ccmNote ?? '')
useEffect(() => { setCcmNoteText(bs?.ccmNote ?? '') }, [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. // Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
if (!bs) { if (!bs) {
return ( return (
@ -1249,12 +1269,12 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước 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 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 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 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 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 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 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) 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) const cmpFull = full - row9 // So sánh với Ngân sách full (full row9)
// Cờ tô màu cảnh báo // Cờ tô màu cảnh báo
const proposalOver = bs.currentProposalTotal > (ev.budgetPeriodAmount ?? 0) && ev.budgetPeriodAmount != null const proposalOver = bs.currentProposalTotal > (draftRow3 ?? 0) && draftRow3 != null
const remainingOver = ev.expectedRemainingAmount != null && ev.expectedRemainingAmount > row7 const remainingOver = draftRow8 != null && draftRow8 > row7
return ( return (
<div className="overflow-hidden rounded-lg border border-slate-300"> <div className="overflow-hidden rounded-lg border border-slate-300">
@ -1417,6 +1437,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
initial={ev.budgetPeriodAmount} initial={ev.budgetPeriodAmount}
saving={adjustMut.isPending} saving={adjustMut.isPending}
label="Ngân sách kỳ này" label="Ngân sách kỳ này"
onLiveChange={setDraftRow3}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })} onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
/> />
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400"></span> ) : 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" label="So sánh với ngân sách kỳ này"
indent indent
sub="= 3 4" 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} third={fmtPct(cmpPeriod, row3) ?? undefined}
danger={cmpPeriod < 0} danger={cmpPeriod < 0}
/> />
@ -1471,7 +1492,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
label="So với NS" label="So với NS"
indent indent
sub="= 5 6" 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} third={fmtPct(cmp56, row5) ?? undefined}
danger={cmp56 < 0} danger={cmp56 < 0}
/> />
@ -1496,10 +1517,11 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
<div className="w-48 shrink-0 text-right"> <div className="w-48 shrink-0 text-right">
{drafterEditable ? ( {drafterEditable ? (
<VndInlineEdit <VndInlineEdit
initial={ev.expectedRemainingAmount} initial={ev.expectedRemainingAmount ?? row7}
allowNegative allowNegative
saving={adjustMut.isPending} saving={adjustMut.isPending}
label="Giá trị thực hiện dự kiến còn lại" 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 })} 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" label="So sánh với Ngân sách full"
indent indent
sub="= Ngân sách full 9" 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} third={fmtPct(cmpFull, full) ?? undefined}
danger={cmpFull < 0} danger={cmpFull < 0}
/> />
@ -2383,7 +2405,7 @@ function HangMucCard({
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
const setWinner = useMutation({ const setWinner = useMutation({
mutationFn: async (supplierId: string) => mutationFn: async (supplierId: string | null) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }), 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] }) }, onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
@ -2552,12 +2574,12 @@ function HangMucCard({
<td className="px-2 py-1.5"> <td className="px-2 py-1.5">
<div className="flex justify-end gap-0.5"> <div className="flex justify-end gap-0.5">
<button <button
onClick={() => setWinner.mutate(s.supplierId)} onClick={() => setWinner.mutate(isWinner ? null : s.supplierId)}
className={cn( className={cn(
'rounded px-1 py-0.5', 'rounded px-1 py-0.5',
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700', 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" /> <Check className="h-3 w-3" />
</button> </button>

View File

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

View File

@ -345,7 +345,7 @@ public record AddSupplierBody(
string? DisplayName, string? ContactName, string? ContactEmail, string? ContactPhone, string? DisplayName, string? ContactName, string? ContactEmail, string? ContactPhone,
string? PaymentTermText, string? Note); string? PaymentTermText, string? Note);
public record SelectWinnerBody(Guid SupplierId); public record SelectWinnerBody(Guid? SupplierId); // null = bỏ chọn NCC (cho phép xóa + điền lại — anh Kiệt FDC C5)
public record DetailBody( public record DetailBody(
string GroupCode, string GroupName, string? ItemCode, string NoiDung, string? DonViTinh, string GroupCode, string GroupName, string? ItemCode, string NoiDung, string? DonViTinh,

View File

@ -387,7 +387,9 @@ public class DeletePurchaseEvaluationQuoteCommandHandler(
// ========== Select winner (NCC được chọn tổng thể) ========== // ========== Select winner (NCC được chọn tổng thể) ==========
public record SelectPurchaseEvaluationWinnerCommand(Guid PurchaseEvaluationId, Guid SupplierId) : IRequest; // SupplierId nullable [anh Kiệt FDC C5]: null = BỎ CHỌN (xóa đơn vị đã chọn, điền
// lại) — un-select winner về none; có giá trị = chọn/đổi NCC (logic cũ giữ 100%).
public record SelectPurchaseEvaluationWinnerCommand(Guid PurchaseEvaluationId, Guid? SupplierId) : IRequest;
public class SelectPurchaseEvaluationWinnerCommandHandler( public class SelectPurchaseEvaluationWinnerCommandHandler(
IApplicationDbContext db, IApplicationDbContext db,
@ -398,15 +400,27 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct) var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId); ?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
_ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct) if (request.SupplierId is Guid supplierId)
?? throw new NotFoundException("Supplier", request.SupplierId); {
// ── Chọn / đổi NCC trúng thầu (logic cũ giữ nguyên 100%) ──
_ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == supplierId, ct)
?? throw new NotFoundException("Supplier", supplierId);
// Verify supplier nằm trong danh sách phiếu // Verify supplier nằm trong danh sách phiếu
var hasSupplier = await db.PurchaseEvaluationSuppliers var hasSupplier = await db.PurchaseEvaluationSuppliers
.AnyAsync(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId && s.SupplierId == request.SupplierId, ct); .AnyAsync(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId && s.SupplierId == supplierId, ct);
if (!hasSupplier) throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá."); if (!hasSupplier) throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá.");
entity.SelectedSupplierId = request.SupplierId; entity.SelectedSupplierId = supplierId;
}
else
{
// ── Bỏ chọn (clear về none) — KHÔNG validate participating list ──
// Row 4 "Giá trị kỳ này" + winnerQuoteTotal tính-khi-đọc theo
// SelectedSupplierId (PurchaseEvaluationFeatures GetDetail) → tự về 0.
// ApprovedPriceAmount/Source là cột chốt giai-đoạn DaDuyet, KHÔNG đụng ở đây.
entity.SelectedSupplierId = null;
}
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{ {
@ -416,7 +430,7 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
PhaseAtChange = entity.Phase, PhaseAtChange = entity.Phase,
UserId = currentUser.UserId, UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email, UserName = currentUser.FullName ?? currentUser.Email,
Summary = "Chọn NCC trúng thầu", Summary = request.SupplierId is null ? "Bỏ chọn NCC trúng thầu" : "Chọn NCC trúng thầu",
}); });
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);

View File

@ -0,0 +1,282 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.PurchaseEvaluations;
using SolutionErp.Domain.Contracts; // ChangelogAction enum (reuse — PurchaseEvaluationChangelog dùng chung)
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [anh Kiệt FDC C5 — test-after] SelectPurchaseEvaluationWinnerCommand: NCC winner
// nullable hoá. Command record `(Guid PurchaseEvaluationId, Guid? SupplierId)`.
// Handler (PurchaseEvaluationDetailFeatures.cs ~388-438) 2 nhánh theo SupplierId:
// • `SupplierId is Guid x` → CHỌN/ĐỔI (logic cũ giữ 100%): NotFound nếu supplier
// không có ở master + Conflict nếu supplier KHÔNG thuộc participating-list của
// phiếu (PurchaseEvaluationSuppliers) + set entity.SelectedSupplierId = x.
// • `SupplierId == null` → BỎ CHỌN (clear về none): set SelectedSupplierId = null,
// BỎ QUA validate participating-list (nhánh MỚI — trục test quan trọng nhất).
// Mọi nhánh ghi 1 Changelog (Header/Update) — Summary phân theo null vs có-giá-trị.
//
// Handler 2-dep (db + ICurrentUser), KHÔNG cần UserManager → SqliteDbFixture đủ nhẹ
// (mirror DepartmentTreeTests). ICurrentUser fake cứng để Changelog ghi UserId/Name.
public class PeSelectWinnerClearTests
{
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid? UserId { get; init; } = Guid.NewGuid();
public string? Email { get; init; } = "procurement@test.local";
public string? FullName { get; init; } = "Procurement Test";
public IReadOnlyList<string> Roles { get; init; } = new[] { AppRoles.Procurement };
public bool IsAuthenticated => UserId is not null;
}
private static SelectPurchaseEvaluationWinnerCommandHandler BuildHandler(
TestApplicationDbContext db)
=> new(db, new FakeCurrentUser());
private static async Task<Supplier> SeedSupplierAsync(
TestApplicationDbContext db, string code)
{
var s = new Supplier { Id = Guid.NewGuid(), Code = code, Name = "NCC " + code };
db.Suppliers.Add(s);
await db.SaveChangesAsync(CancellationToken.None);
return s;
}
// Phiếu PE + (tùy chọn) SelectedSupplierId pre-set. KHÔNG tự thêm participating
// row — caller quyết định để cô lập nhánh clear (skip-list) vs select (need-list).
private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db, Guid? selectedSupplierId, string code = "PE-SW")
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.DangSoanThao,
MaPhieu = code,
TenGoiThau = "Gói thầu chọn NCC",
DrafterUserId = Guid.NewGuid(),
SelectedSupplierId = selectedSupplierId,
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
return pe;
}
// Gắn supplier vào participating-list (PurchaseEvaluationSuppliers) của phiếu.
private static async Task AddParticipatingAsync(
TestApplicationDbContext db, Guid peId, Guid supplierId, int order = 0)
{
db.PurchaseEvaluationSuppliers.Add(new PurchaseEvaluationSupplier
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = peId,
SupplierId = supplierId,
Order = order,
});
await db.SaveChangesAsync(CancellationToken.None);
}
// ============================================================
// 1. CLEAR branch (MỚI — trục quan trọng nhất)
// ============================================================
[Fact]
public async Task Clear_PhieuHasSelectedWinner_SetsSelectedSupplierIdNull()
{
// ⭐ Phiếu đang có SelectedSupplierId = winner (s1) + s1 VẪN nằm trong
// participating-list → gọi SupplierId=null → clear về null, KHÔNG ném.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-A");
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
await AddParticipatingAsync(db, pe.Id, s1.Id);
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
await handler.Handle(cmd, CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().BeNull("bỏ chọn = clear winner về none");
}
[Fact]
public async Task Clear_WinnerNotInParticipatingList_StillClears_NoConflict()
{
// Trục cốt lõi nhánh clear: SKIP validate participating-list. Phiếu có
// SelectedSupplierId = s1 (winner cũ) nhưng s1 KHÔNG còn trong list (vd bị
// gỡ khỏi bảng so sánh). Clear vẫn phải thành công, KHÔNG ném Conflict/NotFound.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-GONE");
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
// CỐ Ý không AddParticipating → s1 không thuộc list của phiếu.
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().NotThrowAsync("clear KHÔNG validate participating-list");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().BeNull();
}
[Fact]
public async Task Clear_PhieuAlreadyNull_IsIdempotent_StaysNull_NoThrow()
{
// Phiếu vốn-dĩ chưa chọn winner (SelectedSupplierId=null) → clear lần nữa =
// idempotent, vẫn null, không ném. Empty participating-list cũng không cản.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var pe = await SeedPeAsync(db, selectedSupplierId: null);
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().NotThrowAsync();
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().BeNull();
}
[Fact]
public async Task Clear_WritesChangelog_WithBoChonSummary()
{
// Nhánh clear vẫn ghi 1 Changelog Header/Update với Summary "Bỏ chọn NCC
// trúng thầu" (phân biệt với "Chọn NCC trúng thầu" của nhánh select).
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-A");
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
await handler.Handle(cmd, CancellationToken.None);
var log = await db.PurchaseEvaluationChangelogs.AsNoTracking()
.Where(c => c.PurchaseEvaluationId == pe.Id)
.SingleAsync();
log.Summary.Should().Be("Bỏ chọn NCC trúng thầu");
log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header);
log.Action.Should().Be(ChangelogAction.Update);
}
// ============================================================
// 2. SELECT branch (regression — logic cũ giữ nguyên 100%)
// ============================================================
[Fact]
public async Task Select_SupplierInParticipatingList_SetsSelectedSupplierId()
{
// Happy-path cũ: supplier hợp lệ (tồn tại master + thuộc participating-list)
// → set SelectedSupplierId = đúng id.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-WIN");
var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id);
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: s1.Id);
await handler.Handle(cmd, CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().Be(s1.Id);
}
[Fact]
public async Task Select_ChangeWinner_FromS1ToS2_BothInList_UpdatesToS2()
{
// Đổi winner: phiếu đang chọn s1 → chọn s2 (cả 2 trong list) → SelectedSupplierId
// chuyển s2 (chứng minh nhánh select overwrite, không chỉ set-from-null).
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-1");
var s2 = await SeedSupplierAsync(db, "NCC-2");
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: s2.Id);
await handler.Handle(cmd, CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().Be(s2.Id);
}
[Fact]
public async Task Select_SupplierNotInParticipatingList_ThrowsConflict_NoMutate()
{
// Supplier tồn tại ở master NHƯNG chưa được thêm vào phiếu → Conflict.
// entity.SelectedSupplierId giữ nguyên (throw TRƯỚC khi gán + SaveChanges).
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-IN"); // trong list
var outsider = await SeedSupplierAsync(db, "NCC-OUT"); // không trong list
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
await AddParticipatingAsync(db, pe.Id, s1.Id);
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: outsider.Id);
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*NCC chưa được thêm vào phiếu*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().Be(s1.Id, "Conflict TRƯỚC gán → winner cũ s1 giữ nguyên");
}
[Fact]
public async Task Select_SupplierNotInMaster_ThrowsNotFound()
{
// Supplier Guid không tồn tại trong Suppliers master → NotFound (check master
// existence TRƯỚC participating-list).
using var fix = new SqliteDbFixture();
var db = fix.Db;
var pe = await SeedPeAsync(db, selectedSupplierId: null);
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: Guid.NewGuid());
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
// ============================================================
// 3. PE existence — chung cả 2 nhánh (check TRƯỚC khi rẽ nhánh SupplierId)
// ============================================================
[Fact]
public async Task UnknownPe_Clear_ThrowsNotFound_BeforeBranch()
{
// PE không tồn tại → NotFound("PurchaseEvaluation") ngay đầu handler, TRƯỚC
// khi xét SupplierId (kể cả nhánh clear null cũng không bỏ qua existence).
using var fix = new SqliteDbFixture();
var db = fix.Db;
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierId: null);
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
[Fact]
public async Task UnknownPe_Select_ThrowsNotFound_BeforeBranch()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-A");
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierId: s1.Id);
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
}