[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>
|
||||
)
|
||||
}
|
||||
@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, ChevronDown, ChevronRight, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
|
||||
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload, Wallet } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
@ -16,6 +16,7 @@ import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { AttachmentPreviewDialog, isPreviewable } from './AttachmentPreviewDialog'
|
||||
import {
|
||||
PeAttachmentPurpose,
|
||||
PeAttachmentPurposeLabel,
|
||||
@ -234,6 +235,12 @@ export function PeDetailTabs({
|
||||
? <LevelOpinionsSectionV2 ev={evaluation} />
|
||||
: <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />}
|
||||
</Section>
|
||||
{/* S22+4 — Feature 2: Section "Điều chỉnh ngân sách" cho phép Drafter
|
||||
(Nháp/Trả lại) HOẶC Approver currentLevel (Đang duyệt) HOẶC Admin
|
||||
sửa Budget link / Manual amount. BE PATCH /budget-adjust riêng. */}
|
||||
<Section title="5. Điều chỉnh ngân sách">
|
||||
<BudgetAdjustSection ev={evaluation} readOnly={readOnly} />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút:
|
||||
@ -954,6 +961,183 @@ function BudgetFieldRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Section "Điều chỉnh ngân sách" (S22+4 — Feature 2) =====
|
||||
// Cho phép Drafter (DangSoanThao/TraLai) HOẶC Approver currentLevel (ChoDuyet)
|
||||
// HOẶC Admin sửa BudgetId + BudgetManualName + BudgetManualAmount qua endpoint
|
||||
// PATCH /budget-adjust riêng. Audit changelog tự động.
|
||||
function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||
const { user: currentUser } = useAuth()
|
||||
const qc = useQueryClient()
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||
const isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id
|
||||
const isDrafterPhase = ev.phase === PurchaseEvaluationPhase.DangSoanThao
|
||||
|| ev.phase === PurchaseEvaluationPhase.TraLai
|
||||
// Approver currentLevel match — phase ChoDuyet + actor in current approval level
|
||||
const actorInCurrentLevel = ev.currentApproval?.approvers?.some(a => a.userId === currentUser?.id) ?? false
|
||||
const isApproverChoDuyet = ev.phase === PurchaseEvaluationPhase.ChoDuyet && actorInCurrentLevel
|
||||
|
||||
const canAdjust = !readOnly && (isAdmin || (isDrafter && isDrafterPhase) || isApproverChoDuyet)
|
||||
|
||||
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId
|
||||
const [manualMode, setManualMode] = useState(initialManual)
|
||||
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '')
|
||||
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0)
|
||||
const [manualName, setManualName] = useState(ev.budgetManualName ?? '')
|
||||
|
||||
const eligibleBudgets = useQuery({
|
||||
queryKey: ['eligible-budgets-adjust', ev.projectId],
|
||||
queryFn: async () => (await api.get<Paged<BudgetListItem>>('/budgets', {
|
||||
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet },
|
||||
})).data.items,
|
||||
enabled: editing && canAdjust,
|
||||
})
|
||||
|
||||
const adjustMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = manualMode
|
||||
? { budgetId: null, budgetManualName: manualName || null, budgetManualAmount: manualAmount > 0 ? manualAmount : null }
|
||||
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
|
||||
await api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Đã điều chỉnh ngân sách')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
setEditing(false)
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
// History defer S22+5 — changelog fetch separate endpoint, KHÔNG có trong
|
||||
// PeDetailBundle. UAT user xem ở Panel "Lịch sử thay đổi" thông qua tab History.
|
||||
|
||||
// Display read mode
|
||||
const displayLink = ev.budget ? (
|
||||
<span>
|
||||
<span className="font-mono text-[11px] text-brand-700">{ev.budget.maNganSach ?? '—'}</span>
|
||||
{' · '}{ev.budget.tenNganSach}
|
||||
{' · '}<span className="font-semibold text-slate-900">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
|
||||
</span>
|
||||
) : (ev.budgetManualAmount != null || ev.budgetManualName) ? (
|
||||
<span>
|
||||
{ev.budgetManualName && <span>{ev.budgetManualName}</span>}
|
||||
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '}
|
||||
{ev.budgetManualAmount != null && (
|
||||
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
|
||||
)}
|
||||
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
|
||||
</span>
|
||||
) : <span className="italic text-slate-400">Chưa có ngân sách</span>
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Read mode + Edit toggle */}
|
||||
{!editing && (
|
||||
<div className="flex items-start justify-between gap-3 rounded border border-emerald-200 bg-emerald-50/40 px-3 py-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Wallet className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
||||
<div className="text-sm text-slate-700">{displayLink}</div>
|
||||
</div>
|
||||
{canAdjust && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setManualMode(initialManual)
|
||||
setBudgetId(ev.budgetId ?? '')
|
||||
setManualAmount(ev.budgetManualAmount ?? 0)
|
||||
setManualName(ev.budgetManualName ?? '')
|
||||
setEditing(true)
|
||||
}}
|
||||
variant="ghost"
|
||||
className="h-7 shrink-0 px-2 text-xs"
|
||||
>
|
||||
<Pencil className="h-3 w-3" /> Điều chỉnh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit mode */}
|
||||
{editing && canAdjust && (
|
||||
<div className="space-y-3 rounded border border-emerald-300 bg-emerald-50/30 p-3">
|
||||
{isApproverChoDuyet && (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800">
|
||||
ⓘ Bạn đang điều chỉnh ngân sách lúc phiếu đang duyệt — thay đổi sẽ được ghi vào lịch sử.
|
||||
</div>
|
||||
)}
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={manualMode}
|
||||
onChange={e => setManualMode(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-slate-300"
|
||||
/>
|
||||
Nhập tay (không link Budget)
|
||||
</label>
|
||||
{!manualMode ? (
|
||||
<div>
|
||||
<Label className="text-[11px]">Chọn Budget từ danh sách</Label>
|
||||
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)} className="text-sm">
|
||||
<option value="">— (huỷ link)</option>
|
||||
{eligibleBudgets.data?.map(b => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-[11px]">Tên (không bắt buộc)</Label>
|
||||
<Input
|
||||
value={manualName}
|
||||
onChange={e => setManualName(e.target.value)}
|
||||
placeholder="vd Ngân sách dự phòng Q2/2026"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">Số tiền (VND)</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(manualAmount)}
|
||||
onChange={e => setManualAmount(parseVnd(e.target.value))}
|
||||
placeholder="0"
|
||||
className="pr-10 font-mono text-right text-sm"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setEditing(false)}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => adjustMut.mutate()}
|
||||
disabled={adjustMut.isPending}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
{adjustMut.isPending ? 'Đang lưu…' : 'Lưu điều chỉnh'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History defer S22+5 — UAT user xem Panel 3 "Lịch sử thay đổi" */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
|
||||
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
|
||||
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
|
||||
@ -1903,6 +2087,7 @@ function SupplierAttachmentsCell({
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [previewAtt, setPreviewAtt] = useState<PeAttachment | null>(null)
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
@ -1965,17 +2150,29 @@ function SupplierAttachmentsCell({
|
||||
{attachments.map(a => (
|
||||
<div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
|
||||
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
|
||||
<button
|
||||
onClick={() => download(a)}
|
||||
className="min-w-0 flex-1 truncate text-left text-slate-700 hover:text-brand-700 hover:underline"
|
||||
title={a.fileName}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-slate-700" title={a.fileName}>
|
||||
{a.fileName}
|
||||
</button>
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{fmtSize(a.fileSize)}</span>
|
||||
<span className="shrink-0 rounded bg-slate-200 px-1 text-[9px] text-slate-600">
|
||||
{PeAttachmentPurposeLabel[a.purpose] ?? ''}
|
||||
</span>
|
||||
{isPreviewable(a.fileName) && (
|
||||
<button
|
||||
onClick={() => setPreviewAtt(a)}
|
||||
className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50"
|
||||
title="Xem trước"
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => download(a)}
|
||||
className="shrink-0 rounded px-1 text-brand-600 hover:bg-brand-50"
|
||||
title="Tải xuống"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
|
||||
@ -1987,6 +2184,15 @@ function SupplierAttachmentsCell({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{previewAtt && (
|
||||
<AttachmentPreviewDialog
|
||||
open
|
||||
evaluationId={evaluationId}
|
||||
attachmentId={previewAtt.id}
|
||||
fileName={previewAtt.fileName}
|
||||
onClose={() => setPreviewAtt(null)}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<div>
|
||||
<input
|
||||
@ -2025,6 +2231,7 @@ function GeneralAttachmentsSection({
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [previewAtt, setPreviewAtt] = useState<PeAttachment | null>(null)
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
@ -2097,13 +2304,9 @@ function GeneralAttachmentsSection({
|
||||
className="flex items-center gap-2 rounded border border-slate-200 bg-white px-3 py-2 text-sm shadow-sm"
|
||||
>
|
||||
<Paperclip className="h-4 w-4 shrink-0 text-brand-500" />
|
||||
<button
|
||||
onClick={() => download(a)}
|
||||
className="min-w-0 flex-1 truncate text-left font-medium text-slate-800 hover:text-brand-700 hover:underline"
|
||||
title={a.fileName}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-slate-800" title={a.fileName}>
|
||||
{a.fileName}
|
||||
</button>
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-slate-500">{fmtSize(a.fileSize)}</span>
|
||||
<span className="shrink-0 rounded bg-brand-50 px-1.5 py-0.5 text-[10px] text-brand-700">
|
||||
{PeAttachmentPurposeLabel[a.purpose] ?? 'Khác'}
|
||||
@ -2111,6 +2314,22 @@ function GeneralAttachmentsSection({
|
||||
<span className="shrink-0 text-[10px] text-slate-400">
|
||||
{new Date(a.createdAt).toLocaleDateString('vi-VN')}
|
||||
</span>
|
||||
{isPreviewable(a.fileName) && (
|
||||
<button
|
||||
onClick={() => setPreviewAtt(a)}
|
||||
className="shrink-0 rounded p-1 text-violet-600 hover:bg-violet-50"
|
||||
title="Xem trước"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => download(a)}
|
||||
className="shrink-0 rounded p-1 text-brand-600 hover:bg-brand-50"
|
||||
title="Tải xuống"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
|
||||
@ -2124,6 +2343,15 @@ function GeneralAttachmentsSection({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{previewAtt && (
|
||||
<AttachmentPreviewDialog
|
||||
open
|
||||
evaluationId={evaluationId}
|
||||
attachmentId={previewAtt.id}
|
||||
fileName={previewAtt.fileName}
|
||||
onClose={() => setPreviewAtt(null)}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<div>
|
||||
<input
|
||||
|
||||
97
fe-user/src/components/pe/AttachmentPreviewDialog.tsx
Normal file
97
fe-user/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>
|
||||
)
|
||||
}
|
||||
@ -6,7 +6,8 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, ChevronDown, ChevronRight, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
|
||||
import { Check, ChevronDown, ChevronRight, Download, Eye, Paperclip, Pencil, Plus, Trash2, Upload, Wallet } from 'lucide-react'
|
||||
import { AttachmentPreviewDialog, isPreviewable } from './AttachmentPreviewDialog'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
@ -240,6 +241,12 @@ export function PeDetailTabs({
|
||||
? <LevelOpinionsSectionV2 ev={evaluation} />
|
||||
: <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />}
|
||||
</Section>
|
||||
{/* S22+4 — Feature 2: Section "Điều chỉnh ngân sách" (mirror fe-admin).
|
||||
Drafter (Nháp/Trả lại) HOẶC Approver currentLevel (Đang duyệt) HOẶC
|
||||
Admin sửa Budget link / Manual amount. BE PATCH /budget-adjust. */}
|
||||
<Section title="5. Điều chỉnh ngân sách">
|
||||
<BudgetAdjustSection ev={evaluation} readOnly={readOnly} />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút:
|
||||
@ -961,6 +968,174 @@ function BudgetFieldRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Section "Điều chỉnh ngân sách" (S22+4 — Feature 2) =====
|
||||
// Mirror fe-admin BudgetAdjustSection — Drafter (Nháp/Trả lại) HOẶC Approver
|
||||
// currentLevel (Đang duyệt) HOẶC Admin sửa Budget link / Manual amount via
|
||||
// PATCH /budget-adjust riêng. Audit changelog tự động.
|
||||
function BudgetAdjustSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||
const { user: currentUser } = useAuth()
|
||||
const qc = useQueryClient()
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||
const isDrafter = currentUser?.id != null && ev.drafterUserId === currentUser.id
|
||||
const isDrafterPhase = ev.phase === PurchaseEvaluationPhase.DangSoanThao
|
||||
|| ev.phase === PurchaseEvaluationPhase.TraLai
|
||||
const actorInCurrentLevel = ev.currentApproval?.approvers?.some(a => a.userId === currentUser?.id) ?? false
|
||||
const isApproverChoDuyet = ev.phase === PurchaseEvaluationPhase.ChoDuyet && actorInCurrentLevel
|
||||
|
||||
const canAdjust = !readOnly && (isAdmin || (isDrafter && isDrafterPhase) || isApproverChoDuyet)
|
||||
|
||||
const initialManual = (ev.budgetManualName !== null || ev.budgetManualAmount !== null) && !ev.budgetId
|
||||
const [manualMode, setManualMode] = useState(initialManual)
|
||||
const [budgetId, setBudgetId] = useState(ev.budgetId ?? '')
|
||||
const [manualAmount, setManualAmount] = useState(ev.budgetManualAmount ?? 0)
|
||||
const [manualName, setManualName] = useState(ev.budgetManualName ?? '')
|
||||
|
||||
const eligibleBudgets = useQuery({
|
||||
queryKey: ['eligible-budgets-adjust', ev.projectId],
|
||||
queryFn: async () => (await api.get<Paged<BudgetListItem>>('/budgets', {
|
||||
params: { pageSize: 100, projectId: ev.projectId, phase: BudgetPhase.DaDuyet },
|
||||
})).data.items,
|
||||
enabled: editing && canAdjust,
|
||||
})
|
||||
|
||||
const adjustMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = manualMode
|
||||
? { budgetId: null, budgetManualName: manualName || null, budgetManualAmount: manualAmount > 0 ? manualAmount : null }
|
||||
: { budgetId: budgetId || null, budgetManualName: null, budgetManualAmount: null }
|
||||
await api.patch(`/purchase-evaluations/${ev.id}/budget-adjust`, payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Đã điều chỉnh ngân sách')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
setEditing(false)
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const displayLink = ev.budget ? (
|
||||
<span>
|
||||
<span className="font-mono text-[11px] text-brand-700">{ev.budget.maNganSach ?? '—'}</span>
|
||||
{' · '}{ev.budget.tenNganSach}
|
||||
{' · '}<span className="font-semibold text-slate-900">{ev.budget.tongNganSach.toLocaleString('vi-VN')} đ</span>
|
||||
</span>
|
||||
) : (ev.budgetManualAmount != null || ev.budgetManualName) ? (
|
||||
<span>
|
||||
{ev.budgetManualName && <span>{ev.budgetManualName}</span>}
|
||||
{ev.budgetManualName && ev.budgetManualAmount != null && ' · '}
|
||||
{ev.budgetManualAmount != null && (
|
||||
<span className="font-semibold text-slate-900">{ev.budgetManualAmount.toLocaleString('vi-VN')} đ</span>
|
||||
)}
|
||||
<span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">nhập tay</span>
|
||||
</span>
|
||||
) : <span className="italic text-slate-400">Chưa có ngân sách</span>
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{!editing && (
|
||||
<div className="flex items-start justify-between gap-3 rounded border border-emerald-200 bg-emerald-50/40 px-3 py-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Wallet className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
||||
<div className="text-sm text-slate-700">{displayLink}</div>
|
||||
</div>
|
||||
{canAdjust && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setManualMode(initialManual)
|
||||
setBudgetId(ev.budgetId ?? '')
|
||||
setManualAmount(ev.budgetManualAmount ?? 0)
|
||||
setManualName(ev.budgetManualName ?? '')
|
||||
setEditing(true)
|
||||
}}
|
||||
variant="ghost"
|
||||
className="h-7 shrink-0 px-2 text-xs"
|
||||
>
|
||||
<Pencil className="h-3 w-3" /> Điều chỉnh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && canAdjust && (
|
||||
<div className="space-y-3 rounded border border-emerald-300 bg-emerald-50/30 p-3">
|
||||
{isApproverChoDuyet && (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800">
|
||||
ⓘ Bạn đang điều chỉnh ngân sách lúc phiếu đang duyệt — thay đổi sẽ được ghi vào lịch sử.
|
||||
</div>
|
||||
)}
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={manualMode}
|
||||
onChange={e => setManualMode(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-slate-300"
|
||||
/>
|
||||
Nhập tay (không link Budget)
|
||||
</label>
|
||||
{!manualMode ? (
|
||||
<div>
|
||||
<Label className="text-[11px]">Chọn Budget từ danh sách</Label>
|
||||
<Select value={budgetId} onChange={e => setBudgetId(e.target.value)} className="text-sm">
|
||||
<option value="">— (huỷ link)</option>
|
||||
{eligibleBudgets.data?.map(b => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.maNganSach ?? '—'} · {b.tenNganSach} · {b.tongNganSach.toLocaleString('vi-VN')} đ
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-[11px]">Tên (không bắt buộc)</Label>
|
||||
<Input
|
||||
value={manualName}
|
||||
onChange={e => setManualName(e.target.value)}
|
||||
placeholder="vd Ngân sách dự phòng Q2/2026"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">Số tiền (VND)</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formatVndInput(manualAmount)}
|
||||
onChange={e => setManualAmount(parseVnd(e.target.value))}
|
||||
placeholder="0"
|
||||
className="pr-10 font-mono text-right text-sm"
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setEditing(false)}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => adjustMut.mutate()}
|
||||
disabled={adjustMut.isPending}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
{adjustMut.isPending ? 'Đang lưu…' : 'Lưu điều chỉnh'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
|
||||
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
|
||||
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
|
||||
@ -1908,6 +2083,7 @@ function SupplierAttachmentsCell({
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [previewAtt, setPreviewAtt] = useState<PeAttachment | null>(null)
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
@ -1970,17 +2146,29 @@ function SupplierAttachmentsCell({
|
||||
{attachments.map(a => (
|
||||
<div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
|
||||
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
|
||||
<button
|
||||
onClick={() => download(a)}
|
||||
className="min-w-0 flex-1 truncate text-left text-slate-700 hover:text-brand-700 hover:underline"
|
||||
title={a.fileName}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-slate-700" title={a.fileName}>
|
||||
{a.fileName}
|
||||
</button>
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{fmtSize(a.fileSize)}</span>
|
||||
<span className="shrink-0 rounded bg-slate-200 px-1 text-[9px] text-slate-600">
|
||||
{PeAttachmentPurposeLabel[a.purpose] ?? ''}
|
||||
</span>
|
||||
{isPreviewable(a.fileName) && (
|
||||
<button
|
||||
onClick={() => setPreviewAtt(a)}
|
||||
className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50"
|
||||
title="Xem trước"
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => download(a)}
|
||||
className="shrink-0 rounded px-1 text-brand-600 hover:bg-brand-50"
|
||||
title="Tải xuống"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
|
||||
@ -1992,6 +2180,15 @@ function SupplierAttachmentsCell({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{previewAtt && (
|
||||
<AttachmentPreviewDialog
|
||||
open
|
||||
evaluationId={evaluationId}
|
||||
attachmentId={previewAtt.id}
|
||||
fileName={previewAtt.fileName}
|
||||
onClose={() => setPreviewAtt(null)}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<div>
|
||||
<input
|
||||
@ -2030,6 +2227,7 @@ function GeneralAttachmentsSection({
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [previewAtt, setPreviewAtt] = useState<PeAttachment | null>(null)
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
@ -2102,13 +2300,9 @@ function GeneralAttachmentsSection({
|
||||
className="flex items-center gap-2 rounded border border-slate-200 bg-white px-3 py-2 text-sm shadow-sm"
|
||||
>
|
||||
<Paperclip className="h-4 w-4 shrink-0 text-brand-500" />
|
||||
<button
|
||||
onClick={() => download(a)}
|
||||
className="min-w-0 flex-1 truncate text-left font-medium text-slate-800 hover:text-brand-700 hover:underline"
|
||||
title={a.fileName}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-slate-800" title={a.fileName}>
|
||||
{a.fileName}
|
||||
</button>
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-slate-500">{fmtSize(a.fileSize)}</span>
|
||||
<span className="shrink-0 rounded bg-brand-50 px-1.5 py-0.5 text-[10px] text-brand-700">
|
||||
{PeAttachmentPurposeLabel[a.purpose] ?? 'Khác'}
|
||||
@ -2116,6 +2310,22 @@ function GeneralAttachmentsSection({
|
||||
<span className="shrink-0 text-[10px] text-slate-400">
|
||||
{new Date(a.createdAt).toLocaleDateString('vi-VN')}
|
||||
</span>
|
||||
{isPreviewable(a.fileName) && (
|
||||
<button
|
||||
onClick={() => setPreviewAtt(a)}
|
||||
className="shrink-0 rounded p-1 text-violet-600 hover:bg-violet-50"
|
||||
title="Xem trước"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => download(a)}
|
||||
className="shrink-0 rounded p-1 text-brand-600 hover:bg-brand-50"
|
||||
title="Tải xuống"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
|
||||
@ -2129,6 +2339,15 @@ function GeneralAttachmentsSection({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{previewAtt && (
|
||||
<AttachmentPreviewDialog
|
||||
open
|
||||
evaluationId={evaluationId}
|
||||
attachmentId={previewAtt.id}
|
||||
fileName={previewAtt.fileName}
|
||||
onClose={() => setPreviewAtt(null)}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<div>
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user