Compare commits

...

2 Commits

Author SHA1 Message Date
30d51c89bb [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
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>
2026-05-13 22:32:56 +07:00
37b51d7f07 [CLAUDE] PurchaseEvaluation: S22+4 Chunk A — BE attachment view endpoint + AdjustBudget command
Feature 1 (attachment preview):
- NEW `GET /api/purchase-evaluations/{id}/attachments/{attId}/view`
- Cùng handler download, override `Content-Disposition: inline` để FE nhúng iframe
- Permission: same scope GET phiếu (Plan E V2 strict scope)

Feature 2 (điều chỉnh ngân sách):
- NEW `AdjustPurchaseEvaluationBudgetCommand` + Handler + Validator
- NEW `PATCH /api/purchase-evaluations/{id}/budget-adjust` body
  `{budgetId, budgetManualName, budgetManualAmount}`
- Phase + actor scope guard:
  * DangSoanThao/TraLai → chỉ Drafter của phiếu
  * ChoDuyet → Approver currentLevel (match ApproverUserId) — V2 only
  * Admin → bypass tất cả
- Audit changelog với diff narrative: "Điều chỉnh ngân sách: link X→Y, số tiền A→Bđ [Drafter/Approver Bước/Cấp/Admin]"
- Tách riêng KHÔNG dùng UpdatePeDraft vì Approver scope KHÔNG nên được edit
  Section 1 fields (TenGoiThau/DiaDiem/MoTa/PaymentTerms)

Verify:
- dotnet build SolutionErp.slnx — 0 err, 2 warn DocxRenderer pre-existing
- Test defer carry Plan C (UAT mode §7) — guard logic critical, ưu tiên cho S23+

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:25:49 +07:00
6 changed files with 830 additions and 26 deletions

View 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>
)
}

View File

@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner' 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 { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
@ -16,6 +16,7 @@ import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { AttachmentPreviewDialog, isPreviewable } from './AttachmentPreviewDialog'
import { import {
PeAttachmentPurpose, PeAttachmentPurpose,
PeAttachmentPurposeLabel, PeAttachmentPurposeLabel,
@ -234,6 +235,12 @@ export function PeDetailTabs({
? <LevelOpinionsSectionV2 ev={evaluation} /> ? <LevelOpinionsSectionV2 ev={evaluation} />
: <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />} : <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />}
</Section> </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> </div>
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút: {/* 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 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) ===== // ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
@ -1903,6 +2087,7 @@ function SupplierAttachmentsCell({
}) { }) {
const qc = useQueryClient() const qc = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [previewAtt, setPreviewAtt] = useState<PeAttachment | null>(null)
const upload = useMutation({ const upload = useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) => {
@ -1965,17 +2150,29 @@ function SupplierAttachmentsCell({
{attachments.map(a => ( {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]"> <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" /> <Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
<button <span className="min-w-0 flex-1 truncate text-slate-700" title={a.fileName}>
onClick={() => download(a)}
className="min-w-0 flex-1 truncate text-left text-slate-700 hover:text-brand-700 hover:underline"
title={a.fileName}
>
{a.fileName} {a.fileName}
</button> </span>
<span className="shrink-0 text-[10px] text-slate-400">{fmtSize(a.fileSize)}</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"> <span className="shrink-0 rounded bg-slate-200 px-1 text-[9px] text-slate-600">
{PeAttachmentPurposeLabel[a.purpose] ?? ''} {PeAttachmentPurposeLabel[a.purpose] ?? ''}
</span> </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 && ( {!readOnly && (
<button <button
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }} onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
@ -1987,6 +2184,15 @@ function SupplierAttachmentsCell({
)} )}
</div> </div>
))} ))}
{previewAtt && (
<AttachmentPreviewDialog
open
evaluationId={evaluationId}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
{!readOnly && ( {!readOnly && (
<div> <div>
<input <input
@ -2025,6 +2231,7 @@ function GeneralAttachmentsSection({
}) { }) {
const qc = useQueryClient() const qc = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [previewAtt, setPreviewAtt] = useState<PeAttachment | null>(null)
const upload = useMutation({ const upload = useMutation({
mutationFn: async (file: File) => { 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" 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" /> <Paperclip className="h-4 w-4 shrink-0 text-brand-500" />
<button <span className="min-w-0 flex-1 truncate font-medium text-slate-800" title={a.fileName}>
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}
>
{a.fileName} {a.fileName}
</button> </span>
<span className="shrink-0 text-[11px] text-slate-500">{fmtSize(a.fileSize)}</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"> <span className="shrink-0 rounded bg-brand-50 px-1.5 py-0.5 text-[10px] text-brand-700">
{PeAttachmentPurposeLabel[a.purpose] ?? 'Khác'} {PeAttachmentPurposeLabel[a.purpose] ?? 'Khác'}
@ -2111,6 +2314,22 @@ function GeneralAttachmentsSection({
<span className="shrink-0 text-[10px] text-slate-400"> <span className="shrink-0 text-[10px] text-slate-400">
{new Date(a.createdAt).toLocaleDateString('vi-VN')} {new Date(a.createdAt).toLocaleDateString('vi-VN')}
</span> </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 && ( {!readOnly && (
<button <button
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }} onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
@ -2124,6 +2343,15 @@ function GeneralAttachmentsSection({
))} ))}
</div> </div>
)} )}
{previewAtt && (
<AttachmentPreviewDialog
open
evaluationId={evaluationId}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
{!readOnly && ( {!readOnly && (
<div> <div>
<input <input

View 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>
)
}

View File

@ -6,7 +6,8 @@ import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner' 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 { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
@ -240,6 +241,12 @@ export function PeDetailTabs({
? <LevelOpinionsSectionV2 ev={evaluation} /> ? <LevelOpinionsSectionV2 ev={evaluation} />
: <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />} : <DepartmentOpinionsSection ev={evaluation} readOnly={opinionsReadOnly} />}
</Section> </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> </div>
{/* Action bar bottom — workspace mode + canEdit + !readOnly. 3 nút: {/* 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 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) ===== // ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
@ -1908,6 +2083,7 @@ function SupplierAttachmentsCell({
}) { }) {
const qc = useQueryClient() const qc = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [previewAtt, setPreviewAtt] = useState<PeAttachment | null>(null)
const upload = useMutation({ const upload = useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) => {
@ -1970,17 +2146,29 @@ function SupplierAttachmentsCell({
{attachments.map(a => ( {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]"> <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" /> <Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
<button <span className="min-w-0 flex-1 truncate text-slate-700" title={a.fileName}>
onClick={() => download(a)}
className="min-w-0 flex-1 truncate text-left text-slate-700 hover:text-brand-700 hover:underline"
title={a.fileName}
>
{a.fileName} {a.fileName}
</button> </span>
<span className="shrink-0 text-[10px] text-slate-400">{fmtSize(a.fileSize)}</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"> <span className="shrink-0 rounded bg-slate-200 px-1 text-[9px] text-slate-600">
{PeAttachmentPurposeLabel[a.purpose] ?? ''} {PeAttachmentPurposeLabel[a.purpose] ?? ''}
</span> </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 && ( {!readOnly && (
<button <button
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }} onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
@ -1992,6 +2180,15 @@ function SupplierAttachmentsCell({
)} )}
</div> </div>
))} ))}
{previewAtt && (
<AttachmentPreviewDialog
open
evaluationId={evaluationId}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
{!readOnly && ( {!readOnly && (
<div> <div>
<input <input
@ -2030,6 +2227,7 @@ function GeneralAttachmentsSection({
}) { }) {
const qc = useQueryClient() const qc = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [previewAtt, setPreviewAtt] = useState<PeAttachment | null>(null)
const upload = useMutation({ const upload = useMutation({
mutationFn: async (file: File) => { 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" 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" /> <Paperclip className="h-4 w-4 shrink-0 text-brand-500" />
<button <span className="min-w-0 flex-1 truncate font-medium text-slate-800" title={a.fileName}>
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}
>
{a.fileName} {a.fileName}
</button> </span>
<span className="shrink-0 text-[11px] text-slate-500">{fmtSize(a.fileSize)}</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"> <span className="shrink-0 rounded bg-brand-50 px-1.5 py-0.5 text-[10px] text-brand-700">
{PeAttachmentPurposeLabel[a.purpose] ?? 'Khác'} {PeAttachmentPurposeLabel[a.purpose] ?? 'Khác'}
@ -2116,6 +2310,22 @@ function GeneralAttachmentsSection({
<span className="shrink-0 text-[10px] text-slate-400"> <span className="shrink-0 text-[10px] text-slate-400">
{new Date(a.createdAt).toLocaleDateString('vi-VN')} {new Date(a.createdAt).toLocaleDateString('vi-VN')}
</span> </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 && ( {!readOnly && (
<button <button
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }} onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
@ -2129,6 +2339,15 @@ function GeneralAttachmentsSection({
))} ))}
</div> </div>
)} )}
{previewAtt && (
<AttachmentPreviewDialog
open
evaluationId={evaluationId}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
{!readOnly && ( {!readOnly && (
<div> <div>
<input <input

View File

@ -52,6 +52,18 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
return NoContent(); 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")] [HttpPost("{id:guid}/transitions")]
public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionPeBody body, CancellationToken ct) 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); 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}")] [HttpDelete("{id:guid}/attachments/{attId:guid}")]
public async Task<IActionResult> DeleteAttachment(Guid id, Guid attId, CancellationToken ct) public async Task<IActionResult> DeleteAttachment(Guid id, Guid attId, CancellationToken ct)
{ {

View File

@ -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 ========== // ========== TRANSITION ==========
public record TransitionPurchaseEvaluationCommand( public record TransitionPurchaseEvaluationCommand(