[CLAUDE] FE-PE: S22+4 Chunk B — Attachment preview dialog + View button + Section "Điều chỉnh ngân sách" (mirror 2 app)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m26s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m26s
Feature 1 (FE attachment preview):
- NEW component `AttachmentPreviewDialog.tsx` (shared 2 app):
* Fetch BE `/view` endpoint as blob → object URL (bearer auth qua axios)
* Render iframe (PDF) hoặc img (image) trong Dialog size=lg
* Helper `isPreviewable(fileName)` check ext PDF/PNG/JPG/JPEG/WEBP/GIF
- Update `SupplierAttachmentsCell` (per-NCC quote files):
* Click filename KHÔNG còn trigger download — chuyển sang explicit buttons
* Eye violet button "Xem trước" khi previewable
* Download brand button cạnh bên (always visible)
- Update `GeneralAttachmentsSection` (bảng so sánh general):
* Same pattern: Eye + Download split buttons
- Word/Excel (.doc/.docx/.xls/.xlsx) → download-only (UAT users mở local Office)
- Mirror fe-admin + fe-user (rule §3.9)
Feature 2 (Section "Điều chỉnh ngân sách"):
- NEW component `BudgetAdjustSection` in PeDetailTabs (mirror 2 app)
- Section 5 cuối Detail view sau "4. Ý kiến cấp duyệt"
- canAdjust 3 scope:
* Admin → bypass
* Drafter của phiếu + Phase DangSoanThao/TraLai
* Approver currentLevel (match approvalFlow.approvers) + Phase ChoDuyet
- 2 mode edit: Select Budget link OR Manual amount + name
- Banner amber khi Approver điều chỉnh trong duyệt (audit notice)
- Save → PATCH /api/purchase-evaluations/{id}/budget-adjust (Chunk A BE)
- History display defer S22+5 (changelogs fetch separate endpoint, không có
trong PeDetailBundle — UAT user xem Panel 3 "Lịch sử thay đổi")
Verify:
- npm run build fe-admin — 577ms pass
- npm run build fe-user — 550ms pass
- dotnet test SolutionErp.slnx — 104/104 PASS regression-free
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
97
fe-admin/src/components/pe/AttachmentPreviewDialog.tsx
Normal file
97
fe-admin/src/components/pe/AttachmentPreviewDialog.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2, AlertTriangle } 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). */
|
||||
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 ext = fileName.toLowerCase().split('.').pop() ?? ''
|
||||
const isImage = ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(ext)
|
||||
|
||||
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])
|
||||
|
||||
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>
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user