From 30d51c89bb5cb70cbd1173e355239a9f449b253b Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Wed, 13 May 2026 22:32:56 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20FE-PE:=20S22+4=20Chunk=20B=20?= =?UTF-8?q?=E2=80=94=20Attachment=20preview=20dialog=20+=20View=20button?= =?UTF-8?q?=20+=20Section=20"=C4=90i=E1=BB=81u=20ch=E1=BB=89nh=20ng=C3=A2n?= =?UTF-8?q?=20s=C3=A1ch"=20(mirror=202=20app)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/pe/AttachmentPreviewDialog.tsx | 97 +++++++ fe-admin/src/components/pe/PeDetailTabs.tsx | 254 +++++++++++++++++- .../components/pe/AttachmentPreviewDialog.tsx | 97 +++++++ fe-user/src/components/pe/PeDetailTabs.tsx | 245 ++++++++++++++++- 4 files changed, 667 insertions(+), 26 deletions(-) create mode 100644 fe-admin/src/components/pe/AttachmentPreviewDialog.tsx create mode 100644 fe-user/src/components/pe/AttachmentPreviewDialog.tsx diff --git a/fe-admin/src/components/pe/AttachmentPreviewDialog.tsx b/fe-admin/src/components/pe/AttachmentPreviewDialog.tsx new file mode 100644 index 0000000..85c9a01 --- /dev/null +++ b/fe-admin/src/components/pe/AttachmentPreviewDialog.tsx @@ -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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( + Đóng} + > +
+ {loading && ( +
+ + Đang tải file… +
+ )} + {error && !loading && ( +
+ +
Không tải được file
+
{error}
+
+ )} + {blobUrl && !loading && !error && ( + isImage + ? {fileName} + :