Files
solution-erp/fe-admin/src/components/pe/AttachmentPreviewDialog.tsx
pqhuy1987 5a6125494b
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m59s
[CLAUDE] PurchaseEvaluation: CV PRO refinement batch (anh Kiệt FDC) — live budget recompute + clear winner + fullscreen preview + hide create c/d
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>
2026-06-23 08:47:51 +07:00

145 lines
5.4 KiB
TypeScript

import { useEffect, useState } from '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'
/** Extensions hỗ trợ preview inline (PDF native + images native). Word/Excel
* KHÔNG support — user phải download mở local Office. */
const PREVIEWABLE_EXT = ['pdf', 'png', 'jpg', 'jpeg', 'webp', 'gif']
export function isPreviewable(fileName: string): boolean {
const ext = fileName.toLowerCase().split('.').pop() ?? ''
return PREVIEWABLE_EXT.includes(ext)
}
type Props = {
open: boolean
evaluationId: string
attachmentId: string
fileName: string
onClose: () => void
}
/** 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).
* [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
let currentUrl: string | null = null
setLoading(true)
setError(null)
api.get(`/purchase-evaluations/${evaluationId}/attachments/${attachmentId}/view`, {
responseType: 'blob',
})
.then(res => {
if (cancelled) return
currentUrl = window.URL.createObjectURL(res.data as Blob)
setBlobUrl(currentUrl)
setLoading(false)
})
.catch(err => {
if (cancelled) return
setError(err?.message ?? 'Lỗi tải file')
setLoading(false)
})
return () => {
cancelled = true
if (currentUrl) window.URL.revokeObjectURL(currentUrl)
setBlobUrl(null)
}
}, [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={() => 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>
<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>
)}
</>
)
}