[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
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:
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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ở. */}
|
||||
|
||||
Reference in New Issue
Block a user