Compare commits
2 Commits
0e707891ff
...
30d51c89bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 30d51c89bb | |||
| 37b51d7f07 |
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
|
||||
|
||||
@ -52,6 +52,18 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// S22+4 — Feature 2: Section "Điều chỉnh ngân sách" tách endpoint riêng.
|
||||
// Cho phép Drafter (DangSoanThao/TraLai) OR Approver currentLevel (ChoDuyet)
|
||||
// OR Admin adjust Budget* fields. Handler kiểm phase + actor scope.
|
||||
[HttpPatch("{id:guid}/budget-adjust")]
|
||||
public async Task<IActionResult> AdjustBudget(Guid id, [FromBody] AdjustBudgetBody body, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new AdjustPurchaseEvaluationBudgetCommand(
|
||||
id, body.BudgetId, body.BudgetManualName, body.BudgetManualAmount), ct);
|
||||
return NoContent();
|
||||
}
|
||||
public record AdjustBudgetBody(Guid? BudgetId, string? BudgetManualName, decimal? BudgetManualAmount);
|
||||
|
||||
[HttpPost("{id:guid}/transitions")]
|
||||
public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionPeBody body, CancellationToken ct)
|
||||
{
|
||||
@ -182,6 +194,17 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
||||
return File(f.Content, f.ContentType, f.FileName);
|
||||
}
|
||||
|
||||
// S22+4 — Inline view endpoint cho FE preview modal (PDF iframe, image <img>).
|
||||
// Cùng handler download, khác Content-Disposition (inline vs attachment).
|
||||
// Permission: same actor scope như GET phiếu (Plan E V2 strict).
|
||||
[HttpGet("{id:guid}/attachments/{attId:guid}/view")]
|
||||
public async Task<IActionResult> ViewAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||
{
|
||||
var f = await mediator.Send(new DownloadPurchaseEvaluationAttachmentQuery(id, attId), ct);
|
||||
Response.Headers["Content-Disposition"] = $"inline; filename=\"{f.FileName}\"";
|
||||
return File(f.Content, f.ContentType);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/attachments/{attId:guid}")]
|
||||
public async Task<IActionResult> DeleteAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||
{
|
||||
|
||||
@ -239,6 +239,146 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// ========== ADJUST BUDGET (S22+4 — Feature 2) ==========
|
||||
// Section "Điều chỉnh ngân sách" cho phép sửa BudgetId + BudgetManualName +
|
||||
// BudgetManualAmount khi:
|
||||
// - Drafter scope (Phase=DangSoanThao OR TraLai) — actor là Drafter của phiếu
|
||||
// - Approver scope (Phase=ChoDuyet) — actor là ApproverUserId của currentLevel
|
||||
// - Admin → bypass
|
||||
//
|
||||
// Tách endpoint riêng (KHÔNG dùng UpdatePeDraft) vì UpdatePeDraft chỉ accept
|
||||
// DangSoanThao/TraLai phase + cập nhật cả Section 1 (TenGoiThau, DiaDiem,
|
||||
// MoTa, PaymentTerms) — Approver KHÔNG nên được edit các field đó khi đang duyệt.
|
||||
// AdjustBudget chỉ adjust Budget* — narrow scope hợp lý.
|
||||
public record AdjustPurchaseEvaluationBudgetCommand(
|
||||
Guid Id,
|
||||
Guid? BudgetId,
|
||||
string? BudgetManualName,
|
||||
decimal? BudgetManualAmount) : IRequest;
|
||||
|
||||
public class AdjustPurchaseEvaluationBudgetCommandValidator : AbstractValidator<AdjustPurchaseEvaluationBudgetCommand>
|
||||
{
|
||||
public AdjustPurchaseEvaluationBudgetCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BudgetManualName).MaximumLength(200);
|
||||
RuleFor(x => x.BudgetManualAmount).GreaterThanOrEqualTo(0).When(x => x.BudgetManualAmount.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
public class AdjustPurchaseEvaluationBudgetCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser) : IRequestHandler<AdjustPurchaseEvaluationBudgetCommand>
|
||||
{
|
||||
public async Task Handle(AdjustPurchaseEvaluationBudgetCommand request, CancellationToken ct)
|
||||
{
|
||||
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
||||
|
||||
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
|
||||
var isDrafter = currentUser.UserId is Guid uid && entity.DrafterUserId == uid;
|
||||
var actorTag = string.Empty;
|
||||
|
||||
if (!isAdmin)
|
||||
{
|
||||
if (entity.Phase == PurchaseEvaluationPhase.DangSoanThao
|
||||
|| entity.Phase == PurchaseEvaluationPhase.TraLai)
|
||||
{
|
||||
// Drafter scope — chỉ Drafter của phiếu được adjust
|
||||
if (!isDrafter)
|
||||
throw new ForbiddenException("Chỉ Drafter của phiếu được điều chỉnh ngân sách khi Nháp/Trả lại.");
|
||||
actorTag = "[Drafter]";
|
||||
}
|
||||
else if (entity.Phase == PurchaseEvaluationPhase.ChoDuyet)
|
||||
{
|
||||
// Approver scope — actor phải là ApproverUserId của currentLevel
|
||||
if (entity.ApprovalWorkflowId is not Guid awId)
|
||||
throw new ConflictException("Phiếu V1 legacy không hỗ trợ điều chỉnh ngân sách lúc đang duyệt.");
|
||||
if (entity.CurrentWorkflowStepIndex is not int csi || entity.CurrentApprovalLevelOrder is not int curLvl)
|
||||
throw new ConflictException("Phiếu chưa init pointer workflow.");
|
||||
if (currentUser.UserId is not Guid actorId)
|
||||
throw new ConflictException("Yêu cầu authenticated user.");
|
||||
|
||||
var workflow = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps).ThenInclude(s => s.Levels)
|
||||
.FirstOrDefaultAsync(w => w.Id == awId, ct)
|
||||
?? throw new NotFoundException("ApprovalWorkflow", awId);
|
||||
var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList();
|
||||
if (csi < 0 || csi >= stepsOrdered.Count)
|
||||
throw new ConflictException("Pointer step out of range — schema lỗi.");
|
||||
var step = stepsOrdered[csi];
|
||||
var level = step.Levels.FirstOrDefault(l => l.Order == curLvl);
|
||||
if (level?.ApproverUserId != actorId)
|
||||
throw new ForbiddenException(
|
||||
$"Chỉ NV phụ trách Bước {step.Order} / Cấp {curLvl} mới được điều chỉnh ngân sách lúc đang duyệt.");
|
||||
actorTag = $"[Approver Bước {step.Order}/Cấp {curLvl}]";
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ConflictException(
|
||||
$"Phase={entity.Phase} không cho phép điều chỉnh ngân sách. " +
|
||||
"Chỉ cho phép khi Nháp/Trả lại (Drafter) hoặc Đang duyệt (Approver).");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
actorTag = "[Admin]";
|
||||
}
|
||||
|
||||
// Validate Budget link nếu thay đổi
|
||||
if (request.BudgetId is Guid bid && bid != entity.BudgetId)
|
||||
{
|
||||
var bg = await db.Budgets.AsNoTracking()
|
||||
.FirstOrDefaultAsync(b => b.Id == bid, ct)
|
||||
?? throw new NotFoundException("Budget", bid);
|
||||
if (bg.ProjectId != entity.ProjectId)
|
||||
throw new ConflictException("Ngân sách phải cùng dự án với phiếu.");
|
||||
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
|
||||
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
|
||||
}
|
||||
|
||||
// Capture old + apply
|
||||
var oldBudgetId = entity.BudgetId;
|
||||
var oldBudgetManualName = entity.BudgetManualName;
|
||||
var oldBudgetManualAmount = entity.BudgetManualAmount;
|
||||
|
||||
entity.BudgetId = request.BudgetId;
|
||||
entity.BudgetManualName = request.BudgetManualName;
|
||||
entity.BudgetManualAmount = request.BudgetManualAmount;
|
||||
|
||||
// Audit changelog with diff (Vietnamese friendly format)
|
||||
var parts = new List<string>();
|
||||
if (oldBudgetId != request.BudgetId)
|
||||
{
|
||||
var oldDesc = oldBudgetId is null ? "(chưa link)" : "Budget#" + oldBudgetId.Value.ToString()[..8];
|
||||
var newDesc = request.BudgetId is null ? "(huỷ link)" : "Budget#" + request.BudgetId.Value.ToString()[..8];
|
||||
parts.Add($"link {oldDesc} → {newDesc}");
|
||||
}
|
||||
if (oldBudgetManualName != request.BudgetManualName)
|
||||
{
|
||||
parts.Add($"tên \"{oldBudgetManualName ?? "(trống)"}\" → \"{request.BudgetManualName ?? "(trống)"}\"");
|
||||
}
|
||||
if (oldBudgetManualAmount != request.BudgetManualAmount)
|
||||
{
|
||||
var oldAmt = oldBudgetManualAmount?.ToString("N0") ?? "(trống)";
|
||||
var newAmt = request.BudgetManualAmount?.ToString("N0") ?? "(trống)";
|
||||
parts.Add($"số tiền {oldAmt}đ → {newAmt}đ");
|
||||
}
|
||||
var diffSummary = parts.Count == 0 ? "không đổi" : string.Join(", ", parts);
|
||||
|
||||
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||
{
|
||||
PurchaseEvaluationId = entity.Id,
|
||||
EntityType = PurchaseEvaluationEntityType.Header,
|
||||
Action = ChangelogAction.Update,
|
||||
PhaseAtChange = entity.Phase,
|
||||
UserId = currentUser.UserId,
|
||||
Summary = $"Điều chỉnh ngân sách: {diffSummary} {actorTag}",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== TRANSITION ==========
|
||||
|
||||
public record TransitionPurchaseEvaluationCommand(
|
||||
|
||||
Reference in New Issue
Block a user