[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:
@ -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